This commit is contained in:
xtremxpert 2025-02-26 10:44:58 -05:00
parent f615f8c960
commit c507e5d416
6 changed files with 196 additions and 85 deletions

View file

@ -48,14 +48,20 @@ class BaseAIProviderInstance(models.Model):
api_key = fields.Char( api_key = fields.Char(
string='API Key', string='API Key',
help='API key if required by the provider', help='API key if required by the provider',
invisible=lambda self: self.provider_type == 'ollama' invisible="[('provider_type', '=', 'ollama')]" # Hide when provider type is ollama
) )
@api.model
@api.onchange('provider_id') def get_default_instance(self):
def _onchange_provider_id(self): """Get the default AI provider instance to use.
if self.provider_id:
self.provider_type = self.provider_id.code Returns:
ai.provider.instance: The default instance to use, or raises UserError if none found
"""
instance = self.env['ai.provider.instance'].search([('active', '=', True)], limit=1)
if not instance:
raise UserError(_('No active AI provider instance found. Please configure one in the settings.'))
return instance
model_ids = fields.One2many( model_ids = fields.One2many(
'ai.model', 'ai.model',
@ -76,6 +82,10 @@ class BaseAIProviderInstance(models.Model):
help='Maximum number of retry attempts for failed API calls' help='Maximum number of retry attempts for failed API calls'
) )
@api.model
def _valid_field_parameter(self, field, name):
return name == 'invisible' or super()._valid_field_parameter(field, name)
_sql_constraints = [ _sql_constraints = [
('name_uniq', ('name_uniq',
'unique(name)', 'unique(name)',
@ -94,4 +104,3 @@ class BaseAIProviderInstance(models.Model):
self.ensure_one() self.ensure_one()
if self.provider_type == 'none': if self.provider_type == 'none':
raise UserError(_('Please select a provider type')) raise UserError(_('Please select a provider type'))

View file

@ -1,6 +1,6 @@
{ {
'name': 'Ollama Integration', 'name': 'Ollama Integration',
'version': '1.0', 'version': '1.0.0',
'category': 'Technical', 'category': 'Technical',
'summary': 'Integration with Ollama AI models', 'summary': 'Integration with Ollama AI models',
'description': """ 'description': """
@ -21,6 +21,7 @@ in your Odoo instance. Features include:
], ],
'data': [ 'data': [
'data/ai_provider_data.xml', 'data/ai_provider_data.xml',
'data/ai_provider_instance_data.xml',
'views/ollama_stats_views.xml', 'views/ollama_stats_views.xml',
'views/ai_provider_instance_views.xml', 'views/ai_provider_instance_views.xml',
'security/ir.model.access.csv', 'security/ir.model.access.csv',

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Default Ollama Provider Instance -->
<record id="ai_provider_instance_ollama_default" model="ai.provider.instance">
<field name="name">Ollama Local</field>
<field name="provider_type">ollama</field>
<field name="host">http://localhost:11434</field>
<field name="model_name">llama3.2</field>
<field name="temperature">0.7</field>
<field name="top_k">40</field>
<field name="top_p">0.9</field>
<field name="repeat_penalty">1.1</field>
<field name="num_ctx">4096</field>
<field name="num_predict">1024</field>
<field name="min_p">0.05</field>
<field name="repeat_last_n">64</field>
<field name="seed">0</field>
<field name="num_gpu">1</field>
<field name="num_thread">8</field>
<field name="mirostat">0</field>
<field name="mirostat_tau">5.0</field>
<field name="mirostat_eta">0.1</field>
<field name="num_batch">8</field>
<field name="num_keep">0</field>
<field name="tfs_z">1.0</field>
<field name="skip_special_tokens" eval="True"/>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,17 @@
def migrate(cr, version):
# Add num_predict column if it doesn't exist
cr.execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name='ai_provider_instance'
AND column_name='num_predict'
) THEN
ALTER TABLE ai_provider_instance
ADD COLUMN num_predict integer DEFAULT 1024;
END IF;
END
$$;
""")

View file

@ -36,16 +36,16 @@ class OllamaAIProviderInstance(models.Model):
else: else:
# Clear Ollama-specific fields # Clear Ollama-specific fields
self.update({ self.update({
'num_ctx': False, 'num_ctx': False, # Context length
'temperature': False, 'temperature': False, # Sampling temperature
'top_p': False, 'top_p': False, # Nucleus sampling threshold
'top_k': False, 'top_k': False, # Top-k sampling threshold
'repeat_penalty': False, 'repeat_penalty': False, # Penalty for repeated tokens
'repeat_last_n': False, 'repeat_last_n': False, # Number of tokens to consider for repeat penalty
'num_thread': False, 'num_thread': False, # Number of CPU threads to use
'num_gpu': False, 'num_gpu': False, # Number of GPUs to use
'num_batch': False, 'num_batch': False, # Batch size for inference
'model_name': False, 'model_name': False, # Model name/path
}) })
# Override default host for Ollama # Override default host for Ollama
@ -53,34 +53,6 @@ class OllamaAIProviderInstance(models.Model):
default='http://localhost:11434', default='http://localhost:11434',
help='Ollama server host URL') help='Ollama server host URL')
@api.onchange('provider_type')
def _onchange_provider_type(self):
"""Handle provider type changes.
When switching to 'ollama':
- Set the provider_id to the Ollama provider
- Set default host if empty
When switching away from 'ollama':
- Clear Ollama-specific fields
"""
if self.provider_type == 'ollama':
# Find and set the Ollama provider
ollama_provider = self.env['ai.provider'].search([('code', '=', 'ollama')], limit=1)
if ollama_provider:
self.provider_id = ollama_provider.id
if not self.host:
self.host = ollama_provider.default_host
else:
# Clear Ollama-specific fields
self.update({
'num_ctx': False, # Context length
'temperature': False, # Sampling temperature
'top_p': False, # Nucleus sampling threshold
'top_k': False, # Top-k sampling threshold
'repeat_penalty': False, # Penalty for repeated tokens
})
def test_connection(self): def test_connection(self):
"""Test the connection to the Ollama server. """Test the connection to the Ollama server.
@ -155,16 +127,27 @@ class OllamaAIProviderInstance(models.Model):
existing.write(vals) existing.write(vals)
else: else:
self.env['ai.model'].create(vals) self.env['ai.model'].create(vals)
# Invalidate the cache to force reload of related records
self.invalidate_recordset(['model_ids'])
# Return action to reload the view completely
return { return {
'type': 'ir.actions.client', 'type': 'ir.actions.act_window',
'tag': 'display_notification', 'res_model': 'ai.provider.instance',
'params': { 'res_id': self.id,
'view_mode': 'form',
'target': 'current',
'flags': {
'mode': 'readonly',
'reload': True, # Force reload
},
'context': {'notification': {
'type': 'success',
'title': _('Success'), 'title': _('Success'),
'message': _('Successfully synchronized %d models', len(models)), 'message': _('Successfully synchronized %d models', len(models)),
'sticky': False, 'sticky': False,
'type': 'success', }}
}
} }
except Exception as e: except Exception as e:
raise UserError(_('Model synchronization failed: %s', str(e))) raise UserError(_('Model synchronization failed: %s', str(e)))

View file

@ -1,4 +1,10 @@
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from odoo.exceptions import UserError
import requests
import logging
import json
_logger = logging.getLogger(__name__)
class OllamaProviderMixin(models.AbstractModel): class OllamaProviderMixin(models.AbstractModel):
"""Mixin model that provides Ollama-specific configuration parameters. """Mixin model that provides Ollama-specific configuration parameters.
@ -27,7 +33,7 @@ class OllamaProviderMixin(models.AbstractModel):
string='Model Name', string='Model Name',
help='Name of the Ollama model to use (e.g. llama2, mistral, codellama)', help='Name of the Ollama model to use (e.g. llama2, mistral, codellama)',
required=True, required=True,
default='llama2') default='deepseek-r1:32b')
# Context Window Configuration # Context Window Configuration
num_ctx = fields.Integer( num_ctx = fields.Integer(
@ -35,7 +41,7 @@ class OllamaProviderMixin(models.AbstractModel):
help='Maximum number of tokens to consider for context. A larger context window allows ' help='Maximum number of tokens to consider for context. A larger context window allows '
'the model to access more historical information but requires more memory. ' 'the model to access more historical information but requires more memory. '
'Range: [0 - 32768].', 'Range: [0 - 32768].',
default=4096) default=8192)
# Generation Parameters # Generation Parameters
temperature = fields.Float( temperature = fields.Float(
@ -43,32 +49,14 @@ class OllamaProviderMixin(models.AbstractModel):
help='Controls randomness in the output. Higher values make the output more random, ' help='Controls randomness in the output. Higher values make the output more random, '
'while lower values make it more focused and deterministic. ' 'while lower values make it more focused and deterministic. '
'Range: [0.0 - 2.0]', 'Range: [0.0 - 2.0]',
default=0.8) default=0.7)
top_p = fields.Float( top_p = fields.Float(
string='Top P', string='Top P (Nucleus Sampling)',
help='Nucleus sampling: only consider the tokens whose cumulative probability exceeds ' help='Limits the cumulative probability of tokens to sample from. Only the most likely '
'this value. Lower values make the output more focused. ' 'tokens with total probability mass of top_p are considered. '
'Range: [0.0 - 1.0]', 'Range: [0.0 - 1.0].',
default=0.9) default=0.9)
top_k = fields.Integer(
string='Top K',
help='Only consider the top K tokens for text generation. Lower values make the '
'output more focused. Set to 0 to disable. '
'Range: [0 - 100]',
default=40)
repeat_penalty = fields.Float(
string='Repeat Penalty',
help='Penalty for repeating tokens. Higher values make the output less repetitive. '
'Range: [0.0 - 2.0]',
default=1.1)
# Advanced Configuration
stop_sequences = fields.Char(
string='Stop Sequences',
help='Comma-separated list of sequences where the model should stop generating further tokens.')
top_k = fields.Integer( top_k = fields.Integer(
string='Top K', string='Top K',
@ -77,13 +65,6 @@ class OllamaProviderMixin(models.AbstractModel):
'Range: [1 - 100].', 'Range: [1 - 100].',
default=40) default=40)
top_p = fields.Float(
string='Top P (Nucleus Sampling)',
help='Limits the cumulative probability of tokens to sample from. Only the most likely '
'tokens with total probability mass of top_p are considered. '
'Range: [0.0 - 1.0].',
default=0.9)
min_p = fields.Float( min_p = fields.Float(
string='Min P', string='Min P',
help='Sets a minimum probability threshold for token selection. Range: [0.0 - 1.0].', help='Sets a minimum probability threshold for token selection. Range: [0.0 - 1.0].',
@ -96,10 +77,97 @@ class OllamaProviderMixin(models.AbstractModel):
default=1.1, default=1.1,
digits=(3, 2)) digits=(3, 2))
# Advanced Configuration
stop_sequences = fields.Char(
string='Stop Sequences',
help='Comma-separated list of sequences where the model should stop generating further tokens.')
num_predict = fields.Integer(
string='Maximum Tokens',
help='Maximum number of tokens to predict. Set to -1 for unlimited.',
default=2048)
repeat_last_n = fields.Integer( repeat_last_n = fields.Integer(
string='Repeat Last N', string='Repeat Last N',
help='Sets the context window for repeat penalty. Range: [0 - 4096]. Default is 64, 0 disables.', help='Sets the context window for repeat penalty. Range: [0 - 4096]. Default is 64, 0 disables.',
default=64) default=64
)
def generate_text(self, prompt, **kwargs):
"""Generate text using the Ollama API.
Args:
prompt (str): The prompt to generate text from
**kwargs: Additional parameters to pass to the API
Returns:
str: The generated text
"""
self.ensure_one()
# Prepare the request
url = f"{self.host}/api/generate"
# Build the request data
data = {
'model': self.model_name,
'prompt': prompt,
'stream': False,
'num_ctx': self.num_ctx,
'temperature': self.temperature,
'top_k': self.top_k,
'top_p': self.top_p,
'repeat_penalty': self.repeat_penalty,
'repeat_last_n': self.repeat_last_n,
'num_predict': self.num_predict,
'min_p': self.min_p,
'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
}
# Add any additional parameters
if kwargs:
data.update(kwargs)
# Make the request
try:
_logger = logging.getLogger(__name__)
_logger.info("Sending request to Ollama API with data: %s", data)
response = requests.post(url, json=data, timeout=self.timeout)
response.raise_for_status()
# Log the raw response
_logger.info("Raw API response: %s", response.text)
# Parse the response
result = response.json()
response_text = result.get('response', '')
# Si la réponse est une chaîne JSON, la parser
try:
if isinstance(response_text, str):
parsed_response = json.loads(response_text)
_logger.info("Parsed nested JSON response: %s", parsed_response)
return parsed_response
else:
_logger.info("Direct response: %s", response_text)
return response_text
except json.JSONDecodeError:
# Si ce n'est pas du JSON valide, retourner le texte tel quel
_logger.info("Non-JSON response: %s", response_text)
return response_text
except requests.exceptions.RequestException as e:
raise UserError(_('Failed to generate text: %s') % str(e))
# Advanced Generation Parameters # Advanced Generation Parameters
seed = fields.Integer( seed = fields.Integer(
@ -174,6 +242,7 @@ class OllamaProviderMixin(models.AbstractModel):
"""Get Ollama-specific options for API calls.""" """Get Ollama-specific options for API calls."""
self.ensure_one() self.ensure_one()
options = { options = {
'model': self.model_name,
'temperature': self.temperature, 'temperature': self.temperature,
'num_ctx': self.num_ctx, 'num_ctx': self.num_ctx,
'num_predict': self.num_predict, 'num_predict': self.num_predict,
@ -192,6 +261,7 @@ class OllamaProviderMixin(models.AbstractModel):
'num_keep': self.num_keep, 'num_keep': self.num_keep,
'tfs_z': self.tfs_z, 'tfs_z': self.tfs_z,
'skip_special_tokens': self.skip_special_tokens, 'skip_special_tokens': self.skip_special_tokens,
'stream': False
} }
if self.stop_sequences: if self.stop_sequences: