This commit is contained in:
xtremxpert 2025-02-19 14:17:32 -05:00
parent e1e8c94c94
commit 9a130bae6b
44 changed files with 4041 additions and 0 deletions

View file

@ -0,0 +1 @@
from . import models

View file

@ -0,0 +1,37 @@
{
'name': 'AI Integration Base',
'version': '1.0',
'category': 'Technical',
'summary': 'Base module for AI integration',
'description': """
AI Integration Base
==================
This module provides the base framework for integrating various AI providers
into Odoo. It includes:
* Abstract interfaces for AI providers
* Base configuration for AI models
* Common utilities for AI integration
""",
'author': 'Bemade',
'website': 'https://www.bemade.org',
'depends': [
'base',
'web',
],
'data': [
'security/ai_security.xml',
'security/ir_rule.xml',
'security/ir.model.access.csv',
'views/menu.xml',
'views/res_company_views.xml',
'views/res_config_settings_views.xml',
'views/ai_provider_instance_views.xml',
'views/ai_model_views.xml',
'views/ai_model_stats_views.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View file

@ -0,0 +1,9 @@
from .mixins import ai_mixin
from . import ai_generation_params
from . import ai_model
from . import ai_provider_interface
from . import ai_provider
from . import ai_provider_instance
from . import res_config_settings
from . import res_company
from . import ai_model_stats

View file

@ -0,0 +1,36 @@
from odoo import models, fields, api, _
class AIGenerationParams(models.AbstractModel):
_name = 'ai.generation.params'
_description = 'AI Generation Parameters'
# Base Generation Parameters
temperature = fields.Float(
string='Temperature',
help='Controls randomness in generation. Higher values make output more random, lower values more deterministic.',
default=0.7)
repeat_penalty = fields.Float(
string='Repeat Penalty',
help='Penalty for repeating tokens. Higher values make repetition less likely.',
default=1.1)
max_tokens = fields.Integer(
string='Max Tokens',
help='Maximum number of tokens to generate.',
default=2048)
stop_sequences = fields.Char(
string='Stop Sequences',
help='Comma-separated list of sequences where generation should stop.',
default='')
frequency_penalty = fields.Float(
string='Frequency Penalty',
help='Penalty for using frequent tokens. Higher values encourage using less frequent tokens.',
default=0.0)
presence_penalty = fields.Float(
string='Presence Penalty',
help='Penalty for using tokens already in the text. Higher values encourage using new tokens.',
default=0.0)

View file

@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class AIModel(models.Model):
_name = 'ai.model'
_description = 'AI Model'
_order = 'sequence, name'
_check_company = False # Disable automatic company checks
active = fields.Boolean(
string='Active',
default=True,
help='Whether this model is active and available for use')
name = fields.Char(
string='Name',
required=True,
help='Name of the AI model'
)
identifier = fields.Char(
string='Identifier',
required=True,
help='Technical identifier of the model (e.g., gpt-3.5-turbo, mistral-7b)'
)
provider_instance_id = fields.Many2one(
'ai.provider.instance',
string='Provider Instance',
required=True,
ondelete='cascade',
help='Provider instance this model belongs to'
)
provider_type = fields.Selection(
related='provider_instance_id.provider_type',
string='Provider Type',
store=True,
readonly=True,
help='Type of AI provider'
)
description = fields.Text(
string='Description',
help='Description of the model and its capabilities'
)
sequence = fields.Integer(
string='Sequence',
default=10,
help='Sequence for ordering models in lists and dropdowns'
)
is_active = fields.Boolean(
string='Active',
default=True,
help='Whether this model is active'
)
context_window = fields.Integer(
string='Context Window',
default=2048,
help='Maximum number of tokens in the context window'
)
_sql_constraints = [
('unique_identifier_provider',
'unique(identifier, provider_instance_id)',
'The model identifier must be unique per provider instance!')
]
def name_get(self):
"""Custom name_get to include provider instance in display name."""
result = []
for model in self:
name = f"{model.name} ({model.provider_instance_id.name})"
result.append((model.id, name))
return result

View file

@ -0,0 +1,81 @@
from odoo import models, fields, api
from datetime import datetime, timedelta
class AIModelStats(models.Model):
_name = 'ai.model.stats'
_description = 'AI Model Usage Statistics'
_order = 'date desc'
model_id = fields.Many2one('ai.model', string='Model', required=True, ondelete='cascade')
provider_instance_id = fields.Many2one(
related='model_id.provider_instance_id',
string='Provider Instance',
store=True)
date = fields.Date(string='Date', required=True, default=fields.Date.context_today)
request_count = fields.Integer(string='Number of Requests', default=0)
token_count = fields.Integer(string='Total Tokens', default=0)
avg_response_time = fields.Float(string='Average Response Time (ms)', digits=(10, 2), default=0)
error_count = fields.Integer(string='Number of Errors', default=0)
version = fields.Char(string='Model Version', help='Version of the model when stats were recorded')
_sql_constraints = [
('unique_model_date', 'unique(model_id, date)', 'Only one stat entry per model per day is allowed.')
]
def _update_stats(self, model, tokens, response_time, error=False, version=None):
"""Update statistics for a model."""
today = fields.Date.context_today(self)
stats = self.search([
('model_id', '=', model.id),
('date', '=', today)
])
if not stats:
stats = self.create({
'model_id': model.id,
'date': today,
'version': version
})
# Update statistics
new_count = stats.request_count + 1
new_tokens = stats.token_count + tokens
new_time = ((stats.avg_response_time * stats.request_count) + response_time) / new_count
new_errors = stats.error_count + (1 if error else 0)
stats.write({
'request_count': new_count,
'token_count': new_tokens,
'avg_response_time': new_time,
'error_count': new_errors,
'version': version or stats.version # Update version if provided
})
@api.model
def get_model_stats(self, model_id, days=30):
"""Get statistics for a model over the specified number of days."""
start_date = fields.Date.today() - timedelta(days=days)
stats = self.search([
('model_id', '=', model_id),
('date', '>=', start_date)
])
return {
'daily_stats': [{
'date': stat.date,
'requests': stat.request_count,
'tokens': stat.token_count,
'response_time': stat.avg_response_time,
'errors': stat.error_count,
'version': stat.version
} for stat in stats],
'summary': {
'total_requests': sum(stat.request_count for stat in stats),
'total_tokens': sum(stat.token_count for stat in stats),
'avg_response_time': sum(stat.avg_response_time * stat.request_count for stat in stats) /
(sum(stat.request_count for stat in stats) if stats else 1),
'total_errors': sum(stat.error_count for stat in stats),
'versions_used': list(set(stat.version for stat in stats if stat.version))
}
}

View file

@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
import logging
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class AIProvider(models.Model):
_name = 'ai.provider'
_description = 'AI Provider'
_inherit = ['ai.provider.interface']
name = fields.Char(string='Name', required=True)
code = fields.Char(string='Code', required=True)
description = fields.Text(string='Description')
default_host = fields.Char(string='Default Host')
active = fields.Boolean(string='Active', default=True)
@api.model
def send_message(self, message, **kwargs):
"""Send a message to the AI provider and get a response.
Args:
message (dict): The message to send
**kwargs: Additional provider-specific parameters
Returns:
str: The response from the AI provider
Raises:
NotImplementedError: Must be implemented by specific providers
"""
raise NotImplementedError(_("Method send_message must be implemented by specific AI providers"))
@api.model
def get_models(self):
"""Get the list of available models from the provider.
Returns:
list: List of model information dictionaries
Raises:
NotImplementedError: Must be implemented by specific providers
"""
raise NotImplementedError(_("Method get_models must be implemented by specific AI providers"))
@api.model
def test_connection(self):
"""Test the connection to the AI provider.
Returns:
bool: True if connection is successful
Raises:
NotImplementedError: Must be implemented by specific providers
"""
raise NotImplementedError(_("Method test_connection must be implemented by specific AI providers"))
def _handle_error(self, error, context=''):
"""Common error handling for AI provider operations.
Args:
error (Exception): The error that occurred
context (str): Additional context about where the error occurred
Returns:
tuple: (success, message)
"""
error_msg = str(error)
log_msg = f"AI Provider Error{' in ' + context if context else ''}: {error_msg}"
_logger.error(log_msg)
return False, error_msg

View file

@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class AIProviderInstance(models.Model):
_name = 'ai.provider.instance'
_description = 'AI Provider Instance'
_order = 'name'
_check_company = False # Disable automatic company checks
_inherit = ['ai.generation.params']
active = fields.Boolean(
string='Active',
default=True,
help='Whether this provider instance is active and available for use')
name = fields.Char(
string='Name',
required=True,
help='Name of this provider instance (e.g., "OpenWebUI Production", "Ollama Local")'
)
provider_id = fields.Many2one(
'ai.provider',
string='Provider',
required=True,
ondelete='restrict',
help='The AI provider configuration'
)
provider_type = fields.Selection(
selection=[
('none', 'None') # Base selection, will be extended by provider modules
],
string='Provider Type',
required=True,
ondelete={'none': 'set default'},
default='none',
help='Type of AI provider'
)
host = fields.Char(
string='Host',
required=True,
help='Host address (e.g., "http://localhost:8080" or "https://api.example.com")'
)
api_key = fields.Char(
string='API Key',
help='API key if required by the provider'
)
@api.onchange('provider_id')
def _onchange_provider_id(self):
if self.provider_id:
self.provider_type = self.provider_id.code
model_ids = fields.One2many(
'ai.model',
'provider_instance_id',
copy=True,
string='Available Models'
)
timeout = fields.Integer(
string='Timeout',
default=60,
help='Maximum wait time for API calls (in seconds)'
)
max_retries = fields.Integer(
string='Max Retries',
default=3,
help='Maximum number of retry attempts for failed API calls'
)
_sql_constraints = [
('name_uniq',
'unique(name)',
'Provider instance name must be unique!')
]
def test_connection(self):
"""Test the connection to this provider instance."""
self.ensure_one()
provider_model = f'ai.provider.{self.provider_type}'
if provider_model not in self.env:
raise UserError(_("Provider type %s is not installed", self.provider_type))
return self.env[provider_model].test_connection(self)
def sync_models(self):
"""Synchronize models from this provider instance."""
self.ensure_one()
provider_model = f'ai.provider.{self.provider_type}'
if provider_model not in self.env:
raise UserError(_("Provider type %s is not installed", self.provider_type))
return self.env[provider_model].sync_models(self)

View file

@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
import logging
from odoo import models, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class AIProviderInterface(models.AbstractModel):
_name = 'ai.provider.interface'
_description = 'AI Provider Interface'
@api.model
def send_message(self, message, **kwargs):
"""Send a message to the AI provider and get a response.
Args:
message (dict): The message to send
**kwargs: Additional provider-specific parameters
Returns:
dict: The response from the AI provider
"""
raise NotImplementedError()
def _get_provider_type(self):
"""Get the provider type code.
Returns:
str: The provider type code
"""
raise NotImplementedError()
def _get_model_info(self, instance, model_name):
"""Get detailed information about a specific model.
Args:
instance (ai.provider.instance): The provider instance
model_name (str): Name of the model
Returns:
dict: Model information
"""
raise NotImplementedError()
def _list_models(self, instance):
"""List available models for this provider instance.
Args:
instance (ai.provider.instance): The provider instance
Returns:
list: List of available models
"""
raise NotImplementedError()

View file

@ -0,0 +1,2 @@
from . import ai_mixin
from . import ai_generation_params

View file

@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class AIGenerationParams(models.AbstractModel):
"""Mixin for common AI generation parameters across different providers."""
_name = 'ai.generation.params'
_description = 'Common AI Generation Parameters'
# Basic Generation Parameters
temperature = fields.Float(
string='Temperature',
help='Sampling temperature. Range: [0.0 - 2.0]. Higher values make output more random, lower values more deterministic.',
default=0.7,
digits=(3, 2))
top_p = fields.Float(
string='Top P',
help='Nucleus sampling: limits cumulative probability of tokens to sample from. Range: [0.0 - 1.0].',
default=0.9,
digits=(3, 2))
max_tokens = fields.Integer(
string='Max Tokens',
help='Maximum number of tokens to generate. Range: [1 - 32768].',
default=2048)
stop_sequences = fields.Char(
string='Stop Sequences',
help='Comma-separated list of sequences where the model should stop generating')
# System Settings
timeout = fields.Integer(
string='Timeout',
help='Request timeout in seconds. Range: [1 - 300].',
default=30)
retry_count = fields.Integer(
string='Retry Count',
help='Number of times to retry failed requests. Range: [0 - 5].',
default=3)
stream_response = fields.Boolean(
string='Stream Response',
help='Enable response streaming for real-time output.',
default=False)
def _get_base_generation_params(self):
"""Get common generation parameters as a dictionary."""
self.ensure_one()
params = {
'temperature': self.temperature,
'top_p': self.top_p,
'max_tokens': self.max_tokens,
'stream': self.stream_response,
}
if self.stop_sequences:
params['stop'] = [
seq.strip()
for seq in self.stop_sequences.split(',')
]
return params

View file

@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
import logging
from typing import List, Dict, Any, Optional
from odoo import models, api, fields, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class AIMixin(models.AbstractModel):
_name = 'ai.mixin'
_description = 'AI Integration Mixin'
def _get_ai_provider_instance(self, provider_instance_id=None):
"""Get the AI provider instance to use.
Args:
provider_instance_id: Optional specific provider instance to use
Returns:
ai.provider.instance: The provider instance to use
Raises:
UserError: If no provider instance is configured or available
"""
if provider_instance_id:
instance = self.env['ai.provider.instance'].browse(provider_instance_id)
if not instance.exists():
raise UserError(_("Invalid provider instance"))
else:
provider_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_provider_instance_id')
if not provider_id:
raise UserError(_("No default AI provider instance configured"))
instance = self.env['ai.provider.instance'].browse(int(provider_id))
if not instance.exists():
raise UserError(_("Default provider instance not found"))
if not instance.is_active:
raise UserError(_("The selected AI provider instance is not active"))
return instance
def _get_ai_model(self, model_id=None, provider_instance=None):
"""Get the AI model to use.
Args:
model_id: Optional specific model to use
provider_instance: Optional provider instance (to avoid duplicate lookup)
Returns:
ai.model: The model to use
Raises:
UserError: If no model is configured or available
"""
provider_instance = provider_instance or self._get_ai_provider_instance()
if model_id:
model = self.env['ai.model'].browse(model_id)
if not model.exists():
raise UserError(_("Invalid AI model"))
if model.provider_instance_id != provider_instance:
raise UserError(_("The specified model does not belong to the selected provider instance"))
else:
model_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_model_id')
if not model_id:
raise UserError(_("No default AI model configured"))
model = self.env['ai.model'].browse(int(model_id))
if not model.exists():
raise UserError(_("Default AI model not found"))
if not model.is_active:
raise UserError(_("The selected AI model is not active"))
return model
def send_ai_message(self, message: Dict[str, Any], provider_instance_id: Optional[int] = None,
model_id: Optional[int] = None, **kwargs) -> str:
"""Send a message to an AI provider instance.
Args:
message: The message to send
provider_instance_id: Optional specific provider instance to use
model_id: Optional specific model to use
**kwargs: Additional provider-specific parameters
Returns:
str: The response from the AI provider
Raises:
UserError: If there's an error with the AI provider
"""
try:
instance = self._get_ai_provider_instance(provider_instance_id)
model = self._get_ai_model(model_id, instance)
return instance.send_message(message, model, **kwargs)
except Exception as e:
_logger.error("Error sending AI message: %s", str(e))
raise UserError(_("Error communicating with AI provider: %s", str(e)))
def process_batch_ai(self, items: List[Any], processor_func: callable,
provider_instance_id: Optional[int] = None,
model_id: Optional[int] = None, **kwargs) -> List[Any]:
"""Process a batch of items using AI.
Args:
items: List of items to process
processor_func: Function that processes each item and returns AI message
provider_instance_id: Optional specific provider instance to use
model_id: Optional specific model to use
**kwargs: Additional parameters passed to processor_func
Returns:
List[Any]: List of processed results
Example:
def _process_item(item, **kwargs):
return {'role': 'user', 'content': f'Analyze: {item.name}'}
results = self.process_batch_ai(items, _process_item)
"""
if not items:
return []
company = self.env.company
batch_size = company.ai_batch_size or 10
results = []
for i in range(0, len(items), batch_size):
batch = items[i:i + batch_size]
batch_messages = [processor_func(item, **kwargs) for item in batch]
for message in batch_messages:
result = self.send_ai_message(
message,
provider_instance_id=provider_instance_id,
model_id=model_id
)
results.append(result)
return results
def _prepare_ai_message(self, **kwargs):
"""Prepare a message to send to the AI provider.
This method should be implemented by models using this mixin.
Returns:
dict: The prepared message
"""
raise NotImplementedError(_("Method _prepare_ai_message must be implemented by models using AI mixin"))

View file

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class ResCompany(models.Model):
_inherit = 'res.company'
def _get_default_provider_instance(self):
"""Get the default AI provider instance from config parameters"""
provider_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_provider_instance_id')
return self.env['ai.provider.instance'].browse(int(provider_id)) if provider_id else False
def _get_default_model(self):
"""Get the default AI model from config parameters"""
model_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_model_id')
return self.env['ai.model'].browse(int(model_id)) if model_id else False
def _get_ai_batch_size(self):
"""Get the AI batch size from config parameters"""
return int(self.env['ir.config_parameter'].sudo().get_param('ai_integration.ai_batch_size', '100'))

View file

@ -0,0 +1,28 @@
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
default_provider_instance_id = fields.Many2one(
'ai.provider.instance',
string='Default AI Provider Instance',
config_parameter='ai_integration.default_provider_instance_id',
default_model='ai.provider.instance')
default_model_id = fields.Many2one(
'ai.model',
string='Default AI Model',
config_parameter='ai_integration.default_model_id',
default_model='ai.model')
ai_batch_size = fields.Integer(
string='AI Batch Processing Size',
config_parameter='ai_integration.ai_batch_size',
default=100,
default_model='ai.provider.instance')
@api.onchange('default_provider_instance_id')
def _onchange_default_provider_instance(self):
"""Reset model when provider instance changes"""
if self.default_provider_instance_id:
self.default_model_id = False

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- AI User Group -->
<record id="group_ai_user" model="res.groups">
<field name="name">AI User</field>
<field name="category_id" ref="base.module_category_services"/>
<field name="comment">Users can use AI services but cannot configure them.</field>
</record>
<!-- AI Manager Group -->
<record id="group_ai_manager" model="res.groups">
<field name="name">AI Manager</field>
<field name="category_id" ref="base.module_category_services"/>
<field name="implied_ids" eval="[(4, ref('group_ai_user'))]"/>
<field name="comment">Full access to AI configuration and usage.</field>
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,9 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_ai_model_user,ai.model.user,model_ai_model,base.group_user,1,0,0,0
access_ai_model_system,ai.model.system,model_ai_model,base.group_system,1,1,1,1
access_ai_provider_instance_user,ai.provider.instance.user,model_ai_provider_instance,base.group_user,1,0,0,0
access_ai_provider_instance_system,ai.provider.instance.system,model_ai_provider_instance,base.group_system,1,1,1,1
access_ai_model_stats_user,ai.model.stats.user,model_ai_model_stats,base.group_user,1,0,0,0
access_ai_model_stats_system,ai.model.stats.system,model_ai_model_stats,base.group_system,1,1,1,1
access_ai_generation_params_user,ai.generation.params.user,model_ai_generation_params,base.group_user,1,0,0,0
access_ai_generation_params_system,ai.generation.params.system,model_ai_generation_params,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ai_model_user ai.model.user model_ai_model base.group_user 1 0 0 0
3 access_ai_model_system ai.model.system model_ai_model base.group_system 1 1 1 1
4 access_ai_provider_instance_user ai.provider.instance.user model_ai_provider_instance base.group_user 1 0 0 0
5 access_ai_provider_instance_system ai.provider.instance.system model_ai_provider_instance base.group_system 1 1 1 1
6 access_ai_model_stats_user ai.model.stats.user model_ai_model_stats base.group_user 1 0 0 0
7 access_ai_model_stats_system ai.model.stats.system model_ai_model_stats base.group_system 1 1 1 1
8 access_ai_generation_params_user ai.generation.params.user model_ai_generation_params base.group_user 1 0 0 0
9 access_ai_generation_params_system ai.generation.params.system model_ai_generation_params base.group_system 1 1 1 1

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- Global Provider Instance Rule -->
<record id="ai_provider_instance_global_rule" model="ir.rule">
<field name="name">AI Provider Instance: Global Access</field>
<field name="model_id" ref="model_ai_provider_instance"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Global Model Rule -->
<record id="ai_model_global_rule" model="ir.rule">
<field name="name">AI Model: Global Access</field>
<field name="model_id" ref="model_ai_model"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_ai_model_stats_list" model="ir.ui.view">
<field name="name">ai.model.stats.list</field>
<field name="model">ai.model.stats</field>
<field name="arch" type="xml">
<list string="Model Statistics">
<field name="date"/>
<field name="model_id"/>
<field name="provider_instance_id"/>
<field name="version"/>
<field name="request_count"/>
<field name="token_count"/>
<field name="avg_response_time"/>
<field name="error_count"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_ai_model_stats_form" model="ir.ui.view">
<field name="name">ai.model.stats.form</field>
<field name="model">ai.model.stats</field>
<field name="arch" type="xml">
<form string="Model Statistics">
<sheet>
<group>
<group>
<field name="date"/>
<field name="model_id"/>
<field name="provider_instance_id"/>
<field name="version"/>
</group>
<group>
<field name="request_count"/>
<field name="token_count"/>
<field name="avg_response_time"/>
<field name="error_count"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_ai_model_stats_search" model="ir.ui.view">
<field name="name">ai.model.stats.search</field>
<field name="model">ai.model.stats</field>
<field name="arch" type="xml">
<search string="Search Model Statistics">
<field name="model_id"/>
<field name="provider_instance_id"/>
<field name="version"/>
<field name="date"/>
<filter string="Today" name="today" domain="[('date','=',context_today().strftime('%Y-%m-%d'))]"/>
<filter string="Last 7 Days" name="last_week" domain="[('date','>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<filter string="Last 30 Days" name="last_month" domain="[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
<group expand="0" string="Group By">
<filter string="Model" name="group_by_model" context="{'group_by': 'model_id'}"/>
<filter string="Provider Instance" name="group_by_provider" context="{'group_by': 'provider_instance_id'}"/>
<filter string="Version" name="group_by_version" context="{'group_by': 'version'}"/>
<filter string="Date" name="group_by_date" context="{'group_by': 'date'}"/>
</group>
</search>
</field>
</record>
<!-- Graph View -->
<record id="view_ai_model_stats_graph" model="ir.ui.view">
<field name="name">ai.model.stats.graph</field>
<field name="model">ai.model.stats</field>
<field name="arch" type="xml">
<graph string="Model Statistics" type="line" sample="1">
<field name="date" type="row"/>
<field name="request_count" type="measure"/>
<field name="token_count" type="measure"/>
<field name="error_count" type="measure"/>
</graph>
</field>
</record>
<!-- Pivot View -->
<record id="view_ai_model_stats_pivot" model="ir.ui.view">
<field name="name">ai.model.stats.pivot</field>
<field name="model">ai.model.stats</field>
<field name="arch" type="xml">
<pivot string="Model Statistics" sample="1">
<field name="model_id" type="row"/>
<field name="provider_instance_id" type="row"/>
<field name="date" type="col"/>
<field name="request_count" type="measure"/>
<field name="token_count" type="measure"/>
<field name="avg_response_time" type="measure"/>
<field name="error_count" type="measure"/>
</pivot>
</field>
</record>
<!-- Action -->
<record id="action_ai_model_stats" model="ir.actions.act_window">
<field name="name">Model Statistics</field>
<field name="res_model">ai.model.stats</field>
<field name="view_mode">list,form,graph,pivot</field>
<field name="context">{'search_default_last_month': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No statistics recorded yet
</p>
<p>
Statistics will be automatically recorded as you use your AI models.
</p>
</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_ai_model_stats"
name="Model Statistics"
parent="menu_ai_integration"
action="action_ai_model_stats"
sequence="30"/>
</odoo>

View file

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="ai_model_view_list" model="ir.ui.view">
<field name="name">ai.model.list</field>
<field name="model">ai.model</field>
<field name="type">list</field>
<field name="arch" type="xml">
<list string="AI Models">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="provider_instance_id"/>
<field name="provider_type"/>
<field name="identifier"/>
<field name="context_window"/>
<field name="active"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="ai_model_view_form" model="ir.ui.view">
<field name="name">ai.model.form</field>
<field name="model">ai.model</field>
<field name="arch" type="xml">
<form string="AI Model">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
</button>
</div>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="e.g. GPT-3.5 Turbo"/></h1>
</div>
<group>
<group>
<field name="provider_instance_id" options="{'no_create': True}"/>
<field name="provider_type"/>
<field name="identifier"/>
<field name="context_window"/>
</group>
<group>
<field name="sequence"/>
</group>
</group>
<notebook>
<page string="Description" name="description">
<field name="description" placeholder="Enter a description of the model and its capabilities..."/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="ai_model_view_search" model="ir.ui.view">
<field name="name">ai.model.search</field>
<field name="model">ai.model</field>
<field name="arch" type="xml">
<search string="Search AI Models">
<field name="name"/>
<field name="identifier"/>
<field name="provider_instance_id"/>
<field name="provider_type"/>
<filter string="Archived" name="inactive" domain="[('is_active', '=', False)]"/>
<group expand="0" string="Group By">
<filter string="Provider Instance" name="group_by_provider" context="{'group_by': 'provider_instance_id'}"/>
<filter string="Provider Type" name="group_by_type" context="{'group_by': 'provider_type'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="ai_model_action" model="ir.actions.act_window">
<field name="name">AI Models</field>
<field name="res_model">ai.model</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="ai_model_view_search"/>
<field name="context">{'search_default_group_by_provider': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No AI models found
</p>
<p>
AI models will be automatically synchronized when you configure and sync a provider instance.
</p>
</field>
</record>
<!-- Menu Items -->
<menuitem id="ai_integration_menu_root"
name="AI Integration"
sequence="50"/>
<menuitem id="ai_integration_menu_config"
name="Configuration"
parent="ai_integration_menu_root"
sequence="100"/>
<menuitem id="ai_model_menu"
name="AI Models"
parent="ai_integration_menu_config"
action="ai_model_action"
sequence="10"/>
</odoo>

View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="ai_provider_instance_view_list" model="ir.ui.view">
<field name="name">ai.provider.instance.list</field>
<field name="model">ai.provider.instance</field>
<field name="arch" type="xml">
<list string="AI Provider Instances">
<field name="name"/>
<field name="provider_type"/>
<field name="host"/>
<field name="active"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="ai_provider_instance_view_form" model="ir.ui.view">
<field name="name">ai.provider.instance.form</field>
<field name="model">ai.provider.instance</field>
<field name="arch" type="xml">
<form string="AI Provider Instance">
<header>
<button name="test_connection"
string="Test Connection"
type="object"
class="oe_highlight"/>
<button name="sync_models"
string="Sync Models"
type="object"
class="btn-primary"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
</button>
</div>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="e.g. OpenWebUI Production"/></h1>
</div>
<group>
<group>
<field name="provider_type"/>
<field name="host"/>
<field name="api_key" password="True"/>
</group>
<group>
<field name="timeout"/>
<field name="max_retries"/>
</group>
</group>
<notebook>
<page string="Models" name="models">
<field name="model_ids" nolabel="1">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="identifier"/>
<field name="context_window"/>
<field name="active"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="ai_provider_instance_view_search" model="ir.ui.view">
<field name="name">ai.provider.instance.search</field>
<field name="model">ai.provider.instance</field>
<field name="arch" type="xml">
<search string="Search AI Provider Instances">
<field name="name"/>
<field name="provider_type"/>
<field name="host"/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group expand="0" string="Group By">
<filter string="Provider Type" name="group_by_type" context="{'group_by': 'provider_type'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="ai_provider_instance_action" model="ir.actions.act_window">
<field name="name">AI Provider Instances</field>
<field name="res_model">ai.provider.instance</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="ai_provider_instance_view_search"/>
<field name="context">{'company_id': False}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first AI provider instance
</p>
<p>
Configure AI provider instances to connect to different AI services.
</p>
</field>
</record>
<!-- Menu Item -->
<menuitem id="ai_provider_instance_menu"
name="Provider Instances"
parent="ai_integration_menu_config"
action="ai_provider_instance_action"
sequence="5"/>
</odoo>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top level menu -->
<menuitem id="menu_ai_integration"
name="AI Integration"
web_icon="ai_integration,static/description/icon.png"
sequence="50"/>
<!-- Configuration menu -->
<menuitem id="ai_integration_menu_config"
name="Configuration"
parent="menu_ai_integration"
sequence="100"/>
</odoo>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add AI Integration to Settings Menu -->
<record id="action_ai_integration_configuration" model="ir.actions.act_window">
<field name="name">AI Integration</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">res.config.settings</field>
<field name="view_mode">form</field>
<field name="target">inline</field>
<field name="context">{'module': 'ai_integration'}</field>
</record>
<menuitem id="menu_ai_integration_configuration"
name="Settings"
parent="ai_integration_menu_config"
action="action_ai_integration_configuration"
sequence="0"
groups="base.group_system"/>
</odoo>

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.ai.integration</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<div class="app_settings_block" data-string="AI Integration" string="AI Integration" data-key="ai_integration">
<h2>AI Integration</h2>
<div class="row mt16 o_settings_container" name="ai_integration_setting_container">
<div class="col-12 col-lg-6 o_setting_box" id="default_provider_settings">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<span class="o_form_label">Default Provider Configuration</span>
<div class="text-muted">
Configure the default AI provider instance and model for this company
</div>
<div class="content-group">
<div class="mt16 row">
<label for="default_provider_instance_id" class="col-lg-3 o_light_label"/>
<field name="default_provider_instance_id" options="{'no_create': True}"/>
</div>
<div class="mt16 row">
<label for="default_model_id" class="col-lg-3 o_light_label"/>
<field name="default_model_id"
options="{'no_create': True}"
invisible="default_provider_instance_id == False"/>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="batch_processing_settings">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<span class="o_form_label">Batch Processing</span>
<div class="text-muted">
Configure batch processing parameters for AI operations
</div>
<div class="content-group">
<div class="mt16 row">
<label for="ai_batch_size" class="col-lg-3 o_light_label"/>
<field name="ai_batch_size"/>
</div>
</div>
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1 @@
from . import models

View file

@ -0,0 +1,35 @@
{
'name': 'ChatGPT-Compatible AI Integration',
'version': '1.0',
'category': 'Technical',
'summary': 'Integration for ChatGPT-compatible AI providers',
'description': """
ChatGPT-Compatible AI Integration
================================
This module provides integration with ChatGPT-compatible AI providers (OpenWebUI, ChatGPT, etc), offering:
* ChatGPT-compatible provider implementation
* Model synchronization
* Advanced parameter configuration
* Usage statistics tracking
* Support for multiple providers using the ChatGPT API format
""",
'author': 'Bemade',
'website': 'https://www.bemade.org',
'depends': [
'ai_integration',
'openwebui_integration'
],
'data': [
'security/ir.model.access.csv',
'views/chatgpt_instance_views.xml',
'data/chatgpt_provider.xml',
],
'external_dependencies': {
'python': ['requests'],
},
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ChatGPT-Compatible Provider -->
<record id="ai_provider_chatgpt" model="ai.provider">
<field name="name">ChatGPT-Compatible</field>
<field name="code">chatgpt</field>
<field name="instance_model">chatgpt.ai.instance</field>
<field name="provider_model">ai.provider.chatgpt</field>
<field name="description">Integration with ChatGPT-compatible AI providers (OpenWebUI, ChatGPT, etc).</field>
<field name="website">https://platform.openai.com/docs/api-reference/chat</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,2 @@
from . import chatgpt_provider
from . import chatgpt_instance

View file

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class ChatGPTInstance(models.Model):
_name = 'chatgpt.ai.instance'
_description = 'ChatGPT-Compatible Instance Configuration'
_inherit = ['ai.provider.instance', 'ai.generation.params']
provider_type = fields.Selection(
selection_add=[('chatgpt', 'ChatGPT-Compatible')],
ondelete={'chatgpt': 'cascade'})
# ChatGPT API Parameters
presence_penalty = fields.Float(
string='Presence Penalty',
help='Penalty for new tokens based on their presence in text. Range: [-2.0 - 2.0].',
default=0.0,
digits=(3, 2))
frequency_penalty = fields.Float(
string='Frequency Penalty',
help='Penalty for new tokens based on their frequency in text. Range: [-2.0 - 2.0].',
default=0.0,
digits=(3, 2))
# Provider Settings
def _get_provider_options(self):
"""Get ChatGPT-compatible options for API calls."""
self.ensure_one()
options = {
'temperature': self.temperature,
'top_p': self.top_p,
'max_tokens': self.max_tokens,
'presence_penalty': self.presence_penalty,
'frequency_penalty': self.frequency_penalty,
'stream': self.stream_response,
}
if self.stop_sequences:
options['stop'] = [
seq.strip()
for seq in self.stop_sequences.split(',')
]
return options

View file

@ -0,0 +1,163 @@
# -*- coding: utf-8 -*-
import json
import logging
import requests
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class ChatGPTProvider(models.AbstractModel):
_name = 'ai.provider.chatgpt'
_description = 'ChatGPT-Compatible AI Provider'
_inherit = ['ai.provider']
def _get_provider_type(self):
return 'chatgpt'
def test_connection(self, instance):
"""Test the connection to the OpenWebUI server."""
try:
response = requests.get(f"{instance.host}/api/v1/models")
if response.status_code != 200:
raise UserError(_(
"Failed to connect to AI server. Status code: %s. Error: %s",
response.status_code, response.text
))
return True
except requests.exceptions.RequestException as e:
raise UserError(_(
"Failed to connect to AI server: %s", str(e)
))
def sync_models(self, instance):
"""Synchronize available models from the OpenWebUI server."""
self.test_connection(instance)
try:
# Get models from provider
response = requests.get(f"{instance.host}/api/v1/models")
models_data = response.json()
# Get existing models for this instance
existing_models = self.env['ai.model'].search([
('provider_instance_id', '=', instance.id)
])
existing_identifiers = {m.identifier: m for m in existing_models}
for model_data in models_data:
identifier = model_data.get('id')
if not identifier:
continue
# Get model details
model_info = self._get_model_info(instance, identifier)
model_details = model_info.get('details', {})
model_values = {
'name': model_data.get('name', identifier),
'identifier': identifier,
'description': model_details.get('description', ''),
'version': model_details.get('version', ''),
'provider_instance_id': instance.id,
}
if identifier in existing_identifiers:
# Update existing model
existing_identifiers[identifier].write(model_values)
else:
# Create new model
self.env['ai.model'].create(model_values)
return True
except requests.exceptions.RequestException as e:
raise UserError(_("Error synchronizing models: %s") % str(e))
def _get_model_info(self, instance, model_name):
"""Get detailed information about a specific model."""
try:
response = requests.get(
f"{instance.host}/api/v1/models/{model_name}/info"
)
if response.status_code == 200:
return response.json()
else:
_logger.error(
"Failed to get model info for %s. Status: %s, Error: %s",
model_name, response.status_code, response.text
)
return {}
except requests.exceptions.RequestException as e:
_logger.error("Error getting model info: %s", str(e))
return {}
def _format_chat_messages(self, messages):
"""Format chat messages for OpenWebUI API."""
formatted_messages = []
for message in messages:
role = message.get('role', 'user')
content = message.get('content', '')
formatted_messages.append({
'role': role,
'content': content
})
return formatted_messages
def generate_response(self, instance, model, messages, **kwargs):
"""Generate a response using the chat completion API."""
try:
# Format messages for OpenWebUI
formatted_messages = self._format_chat_messages(messages)
# Get model options from instance
options = instance._get_provider_options()
# Prepare the request payload
payload = {
'model': model.identifier,
'messages': formatted_messages,
**options
}
# Make the API call
response = requests.post(
f"{instance.host}/api/v1/chat/completions",
json=payload
)
if response.status_code != 200:
raise UserError(_("Failed to generate response: %s") % response.text)
response_data = response.json()
generated_text = response_data.get('choices', [{}])[0].get('message', {}).get('content', '')
# Update statistics
total_tokens = response_data.get('usage', {}).get('total_tokens', 0)
response_time = response_data.get('response_ms', 0)
version = self._get_model_info(instance, model.identifier)\
.get('details', {}).get('version', '')
self._track_model_usage(
model, total_tokens, response_time, version=version
)
# Return the response in a standardized format
return {
'content': generated_text,
'role': 'assistant',
'metadata': {
'total_tokens': total_tokens,
'response_time': response_time,
'model_version': version,
'usage': response_data.get('usage', {})
}
}
except requests.exceptions.RequestException as e:
# Log error in statistics
if model:
self._track_model_usage(model, error=True)
raise UserError(_("Error generating response: %s") % str(e))

View file

@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_openwebui_ai_instance_user,openwebui.ai.instance user,model_openwebui_ai_instance,base.group_user,1,0,0,0
access_openwebui_ai_instance_system,openwebui.ai.instance system,model_openwebui_ai_instance,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_openwebui_ai_instance_user openwebui.ai.instance user model_openwebui_ai_instance base.group_user 1 0 0 0
3 access_openwebui_ai_instance_system openwebui.ai.instance system model_openwebui_ai_instance base.group_system 1 1 1 1

View file

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form View -->
<record id="view_chatgpt_ai_instance_form" model="ir.ui.view">
<field name="name">chatgpt.ai.instance.form</field>
<field name="model">chatgpt.ai.instance</field>
<field name="arch" type="xml">
<form string="ChatGPT-Compatible Instance">
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="e.g. Production OpenWebUI/ChatGPT"/></h1>
</div>
<group>
<group string="Connection">
<field name="host" placeholder="e.g. http://localhost:8080"/>
<field name="api_key"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="is_default"/>
</group>
<group string="Basic Generation">
<field name="temperature"/>
<field name="top_p"/>
<field name="max_tokens"/>
<field name="presence_penalty"/>
<field name="frequency_penalty"/>
</group>
</group>
<group string="Advanced Settings">
<group>
<field name="stop_sequences" placeholder="e.g. END,STOP,DONE"/>
<field name="timeout"/>
<field name="retry_count"/>
</group>
<group>
<field name="stream_response"/>
</group>
</group>
<group string="Models" attrs="{'invisible': [('id', '=', False)]}">
<field name="model_ids" nolabel="1">
<tree>
<field name="name"/>
<field name="identifier"/>
<field name="version"/>
<field name="description"/>
</tree>
</field>
</group>
</sheet>
</form>
</field>
</record>
<!-- Tree View -->
<record id="view_chatgpt_ai_instance_tree" model="ir.ui.view">
<field name="name">chatgpt.ai.instance.tree</field>
<field name="model">chatgpt.ai.instance</field>
<field name="arch" type="xml">
<tree string="ChatGPT-Compatible Instances">
<field name="name"/>
<field name="host"/>
<field name="is_default"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<!-- Search View -->
<record id="view_chatgpt_ai_instance_search" model="ir.ui.view">
<field name="name">chatgpt.ai.instance.search</field>
<field name="model">chatgpt.ai.instance</field>
<field name="arch" type="xml">
<search string="Search ChatGPT-Compatible Instances">
<field name="name"/>
<field name="host"/>
<field name="company_id" groups="base.group_multi_company"/>
<filter string="Default Instance" name="is_default" domain="[('is_default', '=', True)]"/>
<group expand="0" string="Group By">
<filter string="Company" name="company" context="{'group_by': 'company_id'}" groups="base.group_multi_company"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_chatgpt_ai_instance" model="ir.actions.act_window">
<field name="name">ChatGPT-Compatible Instances</field>
<field name="res_model">chatgpt.ai.instance</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first ChatGPT-compatible instance
</p>
<p>
Configure instances to connect to ChatGPT-compatible servers (OpenWebUI, ChatGPT, etc).
</p>
</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_chatgpt_ai_instance"
name="ChatGPT-Compatible"
parent="ai_integration.menu_ai_integration_config"
action="action_chatgpt_ai_instance"
sequence="20"/>
</odoo>

View file

@ -0,0 +1 @@
from . import models

View file

@ -0,0 +1,33 @@
{
'name': 'Ollama Integration',
'version': '1.0',
'category': 'Technical',
'summary': 'Integration with Ollama AI models',
'description': """
Ollama Integration
=================
This module provides integration with Ollama, allowing you to use local AI models
in your Odoo instance. Features include:
* Connection to local Ollama server
* Support for all Ollama models
* Automatic model discovery and synchronization
* Configurable model parameters
""",
'author': 'Bemade',
'website': 'https://www.bemade.org',
'depends': ['ai_integration'],
'data': [
'data/ollama_provider.xml',
'views/ollama_views.xml',
'views/ollama_stats_views.xml',
'security/ir.model.access.csv',
],
'external_dependencies': {
'python': ['requests'],
},
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Declare model inheritance -->
<record id="ollama_provider_inherit" model="ir.model.inherit">
<field name="model">ai.provider.ollama</field>
<field name="parent_id" ref="ai_integration.model_ai_provider_interface"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Register Ollama Provider -->
<record id="ai_provider_ollama" model="ai.provider">
<field name="name">Ollama</field>
<field name="code">ollama</field>
<field name="description">Local AI models powered by Ollama</field>
<field name="default_host">http://localhost:11434</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,4 @@
from . import ollama_provider_mixin
from . import ai_provider_ollama
from . import ollama_instance
from . import ollama_model_stats

View file

@ -0,0 +1,34 @@
from odoo import models, fields, api, _
class OllamaProvider(models.Model):
_name = 'ai.provider.ollama'
_description = 'Ollama AI Provider'
_inherit = ['ai.provider.interface']
provider_type = fields.Selection(
selection=[('ollama', 'Ollama')],
default='ollama',
required=True,
help='Type of AI provider')
# Ollama-specific Parameters
host = fields.Char(
string='Host',
default='http://localhost:11434',
required=True,
help='Ollama server host URL')
def _get_available_models(self):
"""Get list of available models from Ollama server."""
# TODO: Implement model discovery
return []
def _generate_text(self, prompt, **kwargs):
"""Generate text using Ollama model."""
# TODO: Implement text generation
return ""
def _embed_text(self, text, **kwargs):
"""Generate embeddings for text using Ollama model."""
# TODO: Implement text embedding
return []

View file

@ -0,0 +1,82 @@
from odoo import models, fields, api
from datetime import datetime, timedelta
class OllamaModelStats(models.Model):
_name = 'ollama.model.stats'
_description = 'Ollama Model Usage Statistics'
_inherit = ['ai.model.stats']
_order = 'date desc'
model_id = fields.Many2one('ai.model', string='Model', required=True, ondelete='cascade')
date = fields.Date(string='Date', required=True, default=fields.Date.context_today)
request_count = fields.Integer(string='Number of Requests', default=0)
token_count = fields.Integer(string='Total Tokens', default=0)
avg_response_time = fields.Float(string='Average Response Time (ms)', digits=(10, 2), default=0)
error_count = fields.Integer(string='Number of Errors', default=0)
version = fields.Char(string='Model Version', help='Version of the model when stats were recorded')
_sql_constraints = [
('unique_model_date', 'unique(model_id, date)', 'Only one stat entry per model per day is allowed.')
]
def _update_stats(self, model, tokens, response_time, error=False):
"""Update statistics for a model."""
today = fields.Date.context_today(self)
stats = self.search([
('model_id', '=', model.id),
('date', '=', today)
])
if not stats:
# Get model version
version = self.env['ai.provider.ollama']._get_model_info(
model.provider_instance_id,
model.identifier
).get('details', {}).get('sha256', '')[:8] # First 8 chars of SHA
stats = self.create({
'model_id': model.id,
'date': today,
'version': version
})
# Update statistics
new_count = stats.request_count + 1
new_tokens = stats.token_count + tokens
new_time = ((stats.avg_response_time * stats.request_count) + response_time) / new_count
new_errors = stats.error_count + (1 if error else 0)
stats.write({
'request_count': new_count,
'token_count': new_tokens,
'avg_response_time': new_time,
'error_count': new_errors
})
@api.model
def get_model_stats(self, model_id, days=30):
"""Get statistics for a model over the specified number of days."""
start_date = fields.Date.today() - timedelta(days=days)
stats = self.search([
('model_id', '=', model_id),
('date', '>=', start_date)
])
return {
'daily_stats': [{
'date': stat.date,
'requests': stat.request_count,
'tokens': stat.token_count,
'response_time': stat.avg_response_time,
'errors': stat.error_count,
'version': stat.version
} for stat in stats],
'summary': {
'total_requests': sum(stat.request_count for stat in stats),
'total_tokens': sum(stat.token_count for stat in stats),
'avg_response_time': sum(stat.avg_response_time * stat.request_count for stat in stats) /
(sum(stat.request_count for stat in stats) if stats else 1),
'total_errors': sum(stat.error_count for stat in stats),
'versions_used': list(set(stat.version for stat in stats if stat.version))
}
}

View file

@ -0,0 +1,269 @@
import json
import logging
import requests
from odoo import models, fields, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class OllamaProvider(models.Model):
_name = 'ai.provider.ollama'
_description = 'Ollama AI Provider'
_inherit = ['ai.provider.interface']
def _get_provider_type(self):
return 'ollama'
def _get_model_info(self, instance, model_name):
"""Get detailed information about a specific model."""
try:
response = requests.post(
f"{instance.host}/api/show",
json={'name': model_name}
)
if response.status_code == 200:
return response.json()
else:
_logger.error(
"Failed to get model info for %s. Status: %s, Error: %s",
model_name, response.status_code, response.text
)
return None
except requests.exceptions.RequestException as e:
_logger.error("Error getting model info: %s", str(e))
return None
def pull_model(self, instance, model_name):
"""Pull a model from Ollama."""
try:
# Start the pull
response = requests.post(
f"{instance.host}/api/pull",
json={'name': model_name},
stream=True # Enable streaming for progress updates
)
if response.status_code != 200:
raise UserError(_("Failed to pull model %s: %s") %
(model_name, response.text))
# Process the streaming response
for line in response.iter_lines():
if line:
try:
progress = json.loads(line)
status = progress.get('status')
if status:
_logger.info(
"Pulling model %s: %s",
model_name, status
)
except json.JSONDecodeError:
continue
return True
except requests.exceptions.RequestException as e:
raise UserError(_("Error pulling model %s: %s") %
(model_name, str(e)))
def delete_model(self, instance, model_name):
"""Delete a model from Ollama."""
try:
response = requests.delete(
f"{instance.host}/api/delete",
json={'name': model_name}
)
if response.status_code != 200:
raise UserError(_("Failed to delete model %s: %s") %
(model_name, response.text))
return True
except requests.exceptions.RequestException as e:
raise UserError(_("Error deleting model %s: %s") %
(model_name, str(e)))
def test_connection(self, instance):
"""Test the connection to the Ollama server."""
try:
response = requests.get(f"{instance.host}/api/tags")
if response.status_code != 200:
raise UserError(_(
"Failed to connect to Ollama server. Status code: %s. Error: %s",
response.status_code, response.text
))
return True
except requests.exceptions.RequestException as e:
raise UserError(_(
"Failed to connect to Ollama server: %s", str(e)
))
def _format_chat_messages(self, messages):
"""Format chat messages for Ollama API."""
formatted_prompt = ""
for message in messages:
role = message.get('role', 'user')
content = message.get('content', '')
if role == 'system':
formatted_prompt += f"<system>{content}</system>\n"
elif role == 'assistant':
formatted_prompt += f"Assistant: {content}\n"
else: # user
formatted_prompt += f"Human: {content}\n"
return formatted_prompt.strip()
def generate_response(self, instance, model, messages, **kwargs):
"""Generate a response using the chat completion API."""
try:
# Format messages into Ollama's expected format
prompt = self._format_chat_messages(messages)
# Get model options from instance
options = instance._get_provider_options()
# Prepare the request payload
payload = {
'model': model.identifier,
'prompt': prompt,
'stream': False,
**options
}
# Make the API call
response = requests.post(
f"{instance.host}/api/generate",
json=payload
)
if response.status_code != 200:
raise UserError(_("Failed to generate response: %s") % response.text)
response_data = response.json()
generated_text = response_data.get('response', '')
# Update statistics
total_tokens = response_data.get('eval_count', 0)
response_time = response_data.get('total_duration', 0)
version = self._get_model_info(instance, model.identifier)\
.get('details', {}).get('sha256', '')[:8]
self._track_model_usage(
model, total_tokens, response_time, version=version
)
# Return the response in a standardized format
return {
'content': generated_text,
'role': 'assistant',
'metadata': {
'eval_count': response_data.get('eval_count'),
'eval_duration': response_data.get('eval_duration'),
'total_duration': response_data.get('total_duration'),
'load_duration': response_data.get('load_duration'),
}
}
except requests.exceptions.RequestException as e:
# Log error in statistics
if model:
self._track_model_usage(model, error=True)
raise UserError(_("Error generating response: %s") % str(e))
def sync_models(self, instance):
"""Synchronize available models from the Ollama server."""
self.test_connection(instance)
try:
response = requests.get(f"{instance.host}/api/tags")
models_data = response.json().get('models', [])
# Get existing models for this instance
existing_models = self.env['ai.model'].search([
('provider_instance_id', '=', instance.id)
])
existing_identifiers = {m.identifier: m for m in existing_models}
for model_data in models_data:
identifier = model_data.get('name')
if not identifier:
continue
# Get model details
model_info = self._get_model_info(instance, identifier)
model_details = model_info.get('details', {})
model_values = {
'name': identifier.title(),
'identifier': identifier,
'provider_instance_id': instance.id,
'company_id': instance.company_id.id,
'active': True,
'description': f"Ollama model: {identifier}\\nSize: {model_data.get('size', 'Unknown')}\\nModified: {model_data.get('modified', 'Unknown')}",
}
if identifier in existing_identifiers:
# Update existing model
existing_identifiers[identifier].write(model_values)
del existing_identifiers[identifier]
else:
# Create new model
self.env['ai.model'].create(model_values)
# Deactivate models that no longer exist
for model in existing_identifiers.values():
model.active = False
return True
except requests.exceptions.RequestException as e:
raise UserError(_(
"Failed to sync models from Ollama server: %s", str(e)
))
def send_message(self, message, model, **kwargs):
"""Send a message to the Ollama server."""
instance = model.provider_instance_id
try:
# Prepare the request payload
payload = {
'model': model.identifier,
'prompt': message.get('content', ''),
'stream': False,
'options': kwargs.get('options', {}),
}
# Add system message if provided
if message.get('role') == 'system':
payload['system'] = message['content']
# Send the request
response = requests.post(
f"{instance.host}/api/generate",
json=payload,
timeout=(instance.timeout or 30)
)
if response.status_code != 200:
raise UserError(_(
"Ollama server error. Status code: %s. Error: %s",
response.status_code, response.text
))
result = response.json()
return result.get('response', '')
except requests.exceptions.Timeout:
raise UserError(_("Request to Ollama server timed out"))
except requests.exceptions.RequestException as e:
raise UserError(_(
"Error communicating with Ollama server: %s", str(e)
))
except Exception as e:
_logger.error("Unexpected error in Ollama provider: %s", str(e))
raise UserError(_(
"Unexpected error in Ollama provider: %s", str(e)
))

View file

@ -0,0 +1,141 @@
from odoo import models, fields, api, _
class OllamaProviderMixin(models.AbstractModel):
_name = 'ollama.provider.mixin'
_description = 'Ollama Provider Configuration Mixin'
_inherit = ['ai.generation.params']
provider_type = fields.Selection(
selection_add=[('ollama', 'Ollama')],
ondelete={'ollama': 'cascade'})
# Ollama-specific Parameters
num_ctx = fields.Integer(
string='Context Length',
help='Maximum number of tokens to consider for context. Range: [0 - 32768].',
default=4096)
# Advanced Sampling Parameters
top_k = fields.Integer(
string='Top K',
help='Limits the number of tokens to sample from. Range: [1 - 100].',
default=40)
min_p = fields.Float(
string='Min P',
help='Sets a minimum probability threshold for token selection. Range: [0.0 - 1.0].',
default=0.05,
digits=(3, 2))
repeat_penalty = fields.Float(
string='Repeat Penalty',
help='Penalty for repeating tokens. Range: [1.0 - 2.0]. Higher values make repetition less likely.',
default=1.1,
digits=(3, 2))
repeat_last_n = fields.Integer(
string='Repeat Last N',
help='Sets the context window for repeat penalty. Range: [0 - 4096]. Default is 64, 0 disables.',
default=64)
# Advanced Generation Parameters
seed = fields.Integer(
string='Random Seed',
help='Sets the random seed for generation. Range: [0 - 2147483647]. Use 0 for random.',
default=0)
num_gpu = fields.Integer(
string='Number of GPUs',
help='Number of GPUs to use for generation. Range: [0 - 8]. 0 means CPU only.',
default=1)
num_thread = fields.Integer(
string='Number of Threads',
help='Number of CPU threads to use for generation. Range: [1 - 32].',
default=8)
mirostat = fields.Selection([
('0', 'Disabled'),
('1', 'Mirostat'),
('2', 'Mirostat 2.0')],
string='Mirostat Mode',
help='Enable Mirostat sampling for controlling perplexity',
default='0')
mirostat_tau = fields.Float(
string='Mirostat Tau',
help='Mirostat target entropy. Range: [0.0 - 10.0].',
default=5.0,
digits=(3, 2))
mirostat_eta = fields.Float(
string='Mirostat Eta',
help='Mirostat learning rate. Range: [0.0 - 1.0].',
default=0.1,
digits=(3, 2))
# Ollama-specific Response Control
tfs_z = fields.Float(
string='Tail Free Sampling Z',
help='Tail free sampling parameter. Range: [0.0 - 2.0]. Higher value = more focused.',
default=1.0,
digits=(3, 2))
# System Settings
num_batch = fields.Integer(
string='Batch Size',
help='Number of prompts to batch together',
default=8)
num_keep = fields.Integer(
string='Keep Last N Tokens',
help='Number of tokens to keep from initial prompt',
default=0)
skip_special_tokens = fields.Boolean(
string='Skip Special Tokens',
help='Skip special tokens in generation',
default=True)
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
if 'provider_type' in fields_list:
defaults['provider_type'] = 'ollama'
if 'host' in fields_list and not defaults.get('host'):
defaults['host'] = 'http://localhost:11434'
return defaults
def _get_provider_options(self):
"""Get Ollama-specific options for API calls."""
self.ensure_one()
options = {
'temperature': self.temperature,
'num_ctx': self.num_ctx,
'num_predict': self.num_predict,
'top_k': self.top_k,
'top_p': self.top_p,
'min_p': self.min_p,
'repeat_penalty': self.repeat_penalty,
'repeat_last_n': self.repeat_last_n,
'seed': self.seed,
'num_gpu': self.num_gpu,
'num_thread': self.num_thread,
'mirostat': int(self.mirostat),
'mirostat_tau': self.mirostat_tau,
'mirostat_eta': self.mirostat_eta,
'num_batch': self.num_batch,
'num_keep': self.num_keep,
'tfs_z': self.tfs_z,
'skip_special_tokens': self.skip_special_tokens,
}
if self.stop_sequences:
options['stop'] = [
seq.strip()
for seq in self.stop_sequences.split(',')
if seq.strip()
]
return options

View file

@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_ollama_model_stats_user,ollama.model.stats.user,model_ollama_model_stats,base.group_user,1,0,0,0
access_ollama_model_stats_manager,ollama.model.stats.manager,model_ollama_model_stats,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ollama_model_stats_user ollama.model.stats.user model_ollama_model_stats base.group_user 1 0 0 0
3 access_ollama_model_stats_manager ollama.model.stats.manager model_ollama_model_stats base.group_system 1 1 1 1

View file

@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- List View -->
<record id="view_ollama_model_stats_list" model="ir.ui.view">
<field name="name">ollama.model.stats.list</field>
<field name="model">ollama.model.stats</field>
<field name="arch" type="xml">
<list string="Model Statistics">
<field name="date"/>
<field name="model_id"/>
<field name="version"/>
<field name="request_count"/>
<field name="token_count"/>
<field name="avg_response_time"/>
<field name="error_count"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_ollama_model_stats_form" model="ir.ui.view">
<field name="name">ollama.model.stats.form</field>
<field name="model">ollama.model.stats</field>
<field name="arch" type="xml">
<form string="Model Statistics">
<sheet>
<group>
<group>
<field name="date"/>
<field name="model_id"/>
<field name="version"/>
</group>
<group>
<field name="request_count"/>
<field name="token_count"/>
<field name="avg_response_time"/>
<field name="error_count"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_ollama_model_stats_search" model="ir.ui.view">
<field name="name">ollama.model.stats.search</field>
<field name="model">ollama.model.stats</field>
<field name="arch" type="xml">
<search string="Search Model Statistics">
<field name="model_id"/>
<field name="version"/>
<field name="date"/>
<filter string="Today" name="today" domain="[('date','=',context_today().strftime('%Y-%m-%d'))]"/>
<filter string="Last 7 Days" name="last_week" domain="[('date','>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<filter string="Last 30 Days" name="last_month" domain="[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
<group expand="0" string="Group By">
<filter string="Model" name="group_by_model" context="{'group_by': 'model_id'}"/>
<filter string="Version" name="group_by_version" context="{'group_by': 'version'}"/>
<filter string="Date" name="group_by_date" context="{'group_by': 'date'}"/>
</group>
</search>
</field>
</record>
<!-- Graph View -->
<record id="view_ollama_model_stats_graph" model="ir.ui.view">
<field name="name">ollama.model.stats.graph</field>
<field name="model">ollama.model.stats</field>
<field name="arch" type="xml">
<graph string="Model Statistics" type="line" sample="1">
<field name="date" type="row"/>
<field name="request_count" type="measure"/>
<field name="token_count" type="measure"/>
<field name="error_count" type="measure"/>
</graph>
</field>
</record>
<!-- Pivot View -->
<record id="view_ollama_model_stats_pivot" model="ir.ui.view">
<field name="name">ollama.model.stats.pivot</field>
<field name="model">ollama.model.stats</field>
<field name="arch" type="xml">
<pivot string="Model Statistics" sample="1">
<field name="model_id" type="row"/>
<field name="date" type="col"/>
<field name="request_count" type="measure"/>
<field name="token_count" type="measure"/>
<field name="avg_response_time" type="measure"/>
<field name="error_count" type="measure"/>
</pivot>
</field>
</record>
<!-- Action -->
<record id="action_ollama_model_stats" model="ir.actions.act_window">
<field name="name">Model Statistics</field>
<field name="res_model">ollama.model.stats</field>
<field name="view_mode">list,form,graph,pivot</field>
<field name="context">{'search_default_last_month': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No statistics recorded yet
</p>
<p>
Statistics will be automatically recorded as you use your AI models.
</p>
</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_ollama_model_stats"
name="Model Statistics"
parent="ai_integration.menu_ai_integration"
action="action_ollama_model_stats"
sequence="30"/>
</odoo>

View file

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form View -->
<record id="ollama_instance_view_form" model="ir.ui.view">
<field name="name">ollama.instance.form</field>
<field name="model">ai.provider.instance</field>
<field name="type">form</field>
<field name="mode">primary</field>
<field name="inherit_id" ref="ai_integration.ai_provider_instance_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="replace">
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
</button>
</div>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Ollama Settings" name="ollama_settings"
invisible="provider_type != 'ollama'">
<group>
<group string="Generation Parameters">
<field name="num_ctx"/>
<field name="temperature"/>
<field name="top_p"/>
<field name="top_k"/>
<field name="repeat_penalty"/>
</group>
<group string="Advanced Settings">
<field name="stop_sequences" placeholder="Enter comma-separated stop sequences"/>
</group>
</group>
<div class="alert alert-info" role="alert" style="margin-top: 10px;">
<p><strong>Note:</strong> These settings will be used as defaults for all requests to this Ollama instance.
They can be overridden on a per-request basis.</p>
<ul>
<li><strong>Context Length:</strong> Longer context allows the model to consider more previous text but uses more memory.</li>
<li><strong>Temperature:</strong> Higher values (>1.0) make output more random, lower values make it more focused and deterministic.</li>
<li><strong>Top P:</strong> Controls diversity via nucleus sampling. Lower values (0.1) are more focused, higher values (0.9) more diverse.</li>
<li><strong>Top K:</strong> Limits the cumulative probability of tokens to sample from. Lower values are more focused.</li>
<li><strong>Repeat Penalty:</strong> Higher values (>1.0) make the model less likely to repeat itself.</li>
</ul>
</div>
</page>
</xpath>
</field>
</record>
<!-- Tree View -->
<record id="ollama_instance_view_list" model="ir.ui.view">
<field name="name">ollama.instance.list</field>
<field name="model">ai.provider.instance</field>
<field name="type">list</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="provider_type"/>
<field name="host"/>
<field name="num_ctx" optional="show"/>
<field name="temperature" optional="hide"/>
<field name="active"/>
</list>
</field>
</record>
<!-- Search View -->
<record id="ollama_instance_view_search" model="ir.ui.view">
<field name="name">ollama.instance.search</field>
<field name="model">ai.provider.instance</field>
<field name="mode">primary</field>
<field name="inherit_id" ref="ai_integration.ai_provider_instance_view_search"/>
<field name="arch" type="xml">
<xpath expr="//group" position="inside">
<filter string="Context Length" name="group_by_num_ctx"
domain="[('provider_type', '=', 'ollama')]"
context="{'group_by': 'num_ctx'}"/>
</xpath>
</field>
</record>
<!-- Action -->
<record id="action_ollama_instance" model="ir.actions.act_window">
<field name="name">Ollama Instances</field>
<field name="res_model">ai.provider.instance</field>
<field name="view_mode">list,form</field>
<field name="domain">[('provider_type', '=', 'ollama')]</field>
<field name="context">{'default_provider_type': 'ollama'}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first Ollama instance
</p>
<p>
Configure Ollama instances to use local AI models in your Odoo instance.
Make sure you have Ollama installed and running on your server.
</p>
</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_ollama_instance"
name="Ollama Instances"
parent="ai_integration.ai_integration_menu_config"
action="action_ollama_instance"
sequence="20"/>
</odoo>