diff --git a/ai_integration/__init__.py b/ai_integration/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/ai_integration/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/ai_integration/__manifest__.py b/ai_integration/__manifest__.py new file mode 100644 index 0000000..2fe11aa --- /dev/null +++ b/ai_integration/__manifest__.py @@ -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', +} diff --git a/ai_integration/models/__init__.py b/ai_integration/models/__init__.py new file mode 100644 index 0000000..72eaf6c --- /dev/null +++ b/ai_integration/models/__init__.py @@ -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 \ No newline at end of file diff --git a/ai_integration/models/ai_generation_params.py b/ai_integration/models/ai_generation_params.py new file mode 100644 index 0000000..83a6bd5 --- /dev/null +++ b/ai_integration/models/ai_generation_params.py @@ -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) diff --git a/ai_integration/models/ai_model.py b/ai_integration/models/ai_model.py new file mode 100644 index 0000000..0487c7e --- /dev/null +++ b/ai_integration/models/ai_model.py @@ -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 diff --git a/ai_integration/models/ai_model_stats.py b/ai_integration/models/ai_model_stats.py new file mode 100644 index 0000000..89442f6 --- /dev/null +++ b/ai_integration/models/ai_model_stats.py @@ -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)) + } + } diff --git a/ai_integration/models/ai_provider.py b/ai_integration/models/ai_provider.py new file mode 100644 index 0000000..c75232a --- /dev/null +++ b/ai_integration/models/ai_provider.py @@ -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 diff --git a/ai_integration/models/ai_provider_instance.py b/ai_integration/models/ai_provider_instance.py new file mode 100644 index 0000000..14622c3 --- /dev/null +++ b/ai_integration/models/ai_provider_instance.py @@ -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) diff --git a/ai_integration/models/ai_provider_interface.py b/ai_integration/models/ai_provider_interface.py new file mode 100644 index 0000000..5f23fc6 --- /dev/null +++ b/ai_integration/models/ai_provider_interface.py @@ -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() diff --git a/ai_integration/models/mixins/__init__.py b/ai_integration/models/mixins/__init__.py new file mode 100644 index 0000000..6d1b50e --- /dev/null +++ b/ai_integration/models/mixins/__init__.py @@ -0,0 +1,2 @@ +from . import ai_mixin +from . import ai_generation_params diff --git a/ai_integration/models/mixins/ai_generation_params.py b/ai_integration/models/mixins/ai_generation_params.py new file mode 100644 index 0000000..71792aa --- /dev/null +++ b/ai_integration/models/mixins/ai_generation_params.py @@ -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 diff --git a/ai_integration/models/mixins/ai_mixin.py b/ai_integration/models/mixins/ai_mixin.py new file mode 100644 index 0000000..f8516ea --- /dev/null +++ b/ai_integration/models/mixins/ai_mixin.py @@ -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")) diff --git a/ai_integration/models/res_company.py b/ai_integration/models/res_company.py new file mode 100644 index 0000000..07891a7 --- /dev/null +++ b/ai_integration/models/res_company.py @@ -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')) diff --git a/ai_integration/models/res_config_settings.py b/ai_integration/models/res_config_settings.py new file mode 100644 index 0000000..e2a5bed --- /dev/null +++ b/ai_integration/models/res_config_settings.py @@ -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 diff --git a/ai_integration/security/ai_security.xml b/ai_integration/security/ai_security.xml new file mode 100644 index 0000000..9c77467 --- /dev/null +++ b/ai_integration/security/ai_security.xml @@ -0,0 +1,20 @@ + + + + + + AI User + + Users can use AI services but cannot configure them. + + + + + AI Manager + + + Full access to AI configuration and usage. + + + + diff --git a/ai_integration/security/ir.model.access.csv b/ai_integration/security/ir.model.access.csv new file mode 100644 index 0000000..bee26f6 --- /dev/null +++ b/ai_integration/security/ir.model.access.csv @@ -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 diff --git a/ai_integration/security/ir_rule.xml b/ai_integration/security/ir_rule.xml new file mode 100644 index 0000000..0281865 --- /dev/null +++ b/ai_integration/security/ir_rule.xml @@ -0,0 +1,28 @@ + + + + + + AI Provider Instance: Global Access + + [(1, '=', 1)] + + + + + + + + + + AI Model: Global Access + + [(1, '=', 1)] + + + + + + + + diff --git a/ai_integration/views/ai_model_stats_views.xml b/ai_integration/views/ai_model_stats_views.xml new file mode 100644 index 0000000..d756d78 --- /dev/null +++ b/ai_integration/views/ai_model_stats_views.xml @@ -0,0 +1,123 @@ + + + + + ai.model.stats.list + ai.model.stats + + + + + + + + + + + + + + + + + ai.model.stats.form + ai.model.stats + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + + ai.model.stats.search + ai.model.stats + + + + + + + + + + + + + + + + + + + + + + ai.model.stats.graph + ai.model.stats + + + + + + + + + + + + + ai.model.stats.pivot + ai.model.stats + + + + + + + + + + + + + + + + Model Statistics + ai.model.stats + list,form,graph,pivot + {'search_default_last_month': 1} + +

+ No statistics recorded yet +

+

+ Statistics will be automatically recorded as you use your AI models. +

+
+
+ + + +
diff --git a/ai_integration/views/ai_model_views.xml b/ai_integration/views/ai_model_views.xml new file mode 100644 index 0000000..85e8372 --- /dev/null +++ b/ai_integration/views/ai_model_views.xml @@ -0,0 +1,110 @@ + + + + + ai.model.list + ai.model + list + + + + + + + + + + + + + + + + ai.model.form + ai.model + +
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + +
+
+
+
+ + + + ai.model.search + ai.model + + + + + + + + + + + + + + + + + + + AI Models + ai.model + list,form + + {'search_default_group_by_provider': 1} + +

+ No AI models found +

+

+ AI models will be automatically synchronized when you configure and sync a provider instance. +

+
+
+ + + + + + + +
diff --git a/ai_integration/views/ai_provider_instance_views.xml b/ai_integration/views/ai_provider_instance_views.xml new file mode 100644 index 0000000..4630d19 --- /dev/null +++ b/ai_integration/views/ai_provider_instance_views.xml @@ -0,0 +1,112 @@ + + + + + ai.provider.instance.list + ai.provider.instance + + + + + + + + + + + + + ai.provider.instance.form + ai.provider.instance + +
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + ai.provider.instance.search + ai.provider.instance + + + + + + + + + + + + + + + + AI Provider Instances + ai.provider.instance + list,form + + {'company_id': False} + +

+ Create your first AI provider instance +

+

+ Configure AI provider instances to connect to different AI services. +

+
+
+ + + +
diff --git a/ai_integration/views/menu.xml b/ai_integration/views/menu.xml new file mode 100644 index 0000000..47552f7 --- /dev/null +++ b/ai_integration/views/menu.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/ai_integration/views/res_company_views.xml b/ai_integration/views/res_company_views.xml new file mode 100644 index 0000000..37a2c6e --- /dev/null +++ b/ai_integration/views/res_company_views.xml @@ -0,0 +1,19 @@ + + + + + AI Integration + ir.actions.act_window + res.config.settings + form + inline + {'module': 'ai_integration'} + + + + diff --git a/ai_integration/views/res_config_settings_views.xml b/ai_integration/views/res_config_settings_views.xml new file mode 100644 index 0000000..914bc2b --- /dev/null +++ b/ai_integration/views/res_config_settings_views.xml @@ -0,0 +1,56 @@ + + + + res.config.settings.view.form.inherit.ai.integration + res.config.settings + + + +
+

AI Integration

+
+
+
+
+ Default Provider Configuration +
+ Configure the default AI provider instance and model for this company +
+
+
+
+
+
+
+
+
+ +
+
+
+ Batch Processing +
+ Configure batch processing parameters for AI operations +
+
+
+
+
+
+
+
+ + +
+ + + + diff --git a/chatgpt_ai_integration/__init__.py b/chatgpt_ai_integration/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/chatgpt_ai_integration/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/chatgpt_ai_integration/__manifest__.py b/chatgpt_ai_integration/__manifest__.py new file mode 100644 index 0000000..f397818 --- /dev/null +++ b/chatgpt_ai_integration/__manifest__.py @@ -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', +} diff --git a/chatgpt_ai_integration/data/chatgpt_provider.xml b/chatgpt_ai_integration/data/chatgpt_provider.xml new file mode 100644 index 0000000..061810b --- /dev/null +++ b/chatgpt_ai_integration/data/chatgpt_provider.xml @@ -0,0 +1,14 @@ + + + + + + ChatGPT-Compatible + chatgpt + chatgpt.ai.instance + ai.provider.chatgpt + Integration with ChatGPT-compatible AI providers (OpenWebUI, ChatGPT, etc). + https://platform.openai.com/docs/api-reference/chat + + + diff --git a/chatgpt_ai_integration/models/__init__.py b/chatgpt_ai_integration/models/__init__.py new file mode 100644 index 0000000..9ae9743 --- /dev/null +++ b/chatgpt_ai_integration/models/__init__.py @@ -0,0 +1,2 @@ +from . import chatgpt_provider +from . import chatgpt_instance diff --git a/chatgpt_ai_integration/models/chatgpt_instance.py b/chatgpt_ai_integration/models/chatgpt_instance.py new file mode 100644 index 0000000..9c1b15a --- /dev/null +++ b/chatgpt_ai_integration/models/chatgpt_instance.py @@ -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 diff --git a/chatgpt_ai_integration/models/chatgpt_provider.py b/chatgpt_ai_integration/models/chatgpt_provider.py new file mode 100644 index 0000000..8dd2201 --- /dev/null +++ b/chatgpt_ai_integration/models/chatgpt_provider.py @@ -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)) diff --git a/chatgpt_ai_integration/security/ir.model.access.csv b/chatgpt_ai_integration/security/ir.model.access.csv new file mode 100644 index 0000000..ca4dd20 --- /dev/null +++ b/chatgpt_ai_integration/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_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 diff --git a/chatgpt_ai_integration/views/chatgpt_instance_views.xml b/chatgpt_ai_integration/views/chatgpt_instance_views.xml new file mode 100644 index 0000000..cd547bb --- /dev/null +++ b/chatgpt_ai_integration/views/chatgpt_instance_views.xml @@ -0,0 +1,106 @@ + + + + + chatgpt.ai.instance.form + chatgpt.ai.instance + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + chatgpt.ai.instance.tree + chatgpt.ai.instance + + + + + + + + + + + + + chatgpt.ai.instance.search + chatgpt.ai.instance + + + + + + + + + + + + + + + + ChatGPT-Compatible Instances + chatgpt.ai.instance + tree,form + +

+ Create your first ChatGPT-compatible instance +

+

+ Configure instances to connect to ChatGPT-compatible servers (OpenWebUI, ChatGPT, etc). +

+
+
+ + + +
diff --git a/ollama_ai_integration/__init__.py b/ollama_ai_integration/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/ollama_ai_integration/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/ollama_ai_integration/__manifest__.py b/ollama_ai_integration/__manifest__.py new file mode 100644 index 0000000..6d98e35 --- /dev/null +++ b/ollama_ai_integration/__manifest__.py @@ -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', +} diff --git a/ollama_ai_integration/data/ir_model_inherit.xml b/ollama_ai_integration/data/ir_model_inherit.xml new file mode 100644 index 0000000..a1c3091 --- /dev/null +++ b/ollama_ai_integration/data/ir_model_inherit.xml @@ -0,0 +1,10 @@ + + + + + + ai.provider.ollama + + + + diff --git a/ollama_ai_integration/data/ollama_provider.xml b/ollama_ai_integration/data/ollama_provider.xml new file mode 100644 index 0000000..03a101a --- /dev/null +++ b/ollama_ai_integration/data/ollama_provider.xml @@ -0,0 +1,13 @@ + + + + + + Ollama + ollama + Local AI models powered by Ollama + http://localhost:11434 + + + + diff --git a/ollama_ai_integration/doc/ollama_api.md b/ollama_ai_integration/doc/ollama_api.md new file mode 100644 index 0000000..603b15b --- /dev/null +++ b/ollama_ai_integration/doc/ollama_api.md @@ -0,0 +1,1627 @@ +# API + +## Endpoints + +- [Generate a completion](#generate-a-completion) +- [Generate a chat completion](#generate-a-chat-completion) +- [Create a Model](#create-a-model) +- [List Local Models](#list-local-models) +- [Show Model Information](#show-model-information) +- [Copy a Model](#copy-a-model) +- [Delete a Model](#delete-a-model) +- [Pull a Model](#pull-a-model) +- [Push a Model](#push-a-model) +- [Generate Embeddings](#generate-embeddings) +- [List Running Models](#list-running-models) +- [Version](#version) + +## Conventions + +### Model names + +Model names follow a `model:tag` format, where `model` can have an optional namespace such as `example/model`. Some examples are `orca-mini:3b-q4_1` and `llama3:70b`. The tag is optional and, if not provided, will default to `latest`. The tag is used to identify a specific version. + +### Durations + +All durations are returned in nanoseconds. + +### Streaming responses + +Certain endpoints stream responses as JSON objects. Streaming can be disabled by providing `{"stream": false}` for these endpoints. + +## Generate a completion + +``` +POST /api/generate +``` + +Generate a response for a given prompt with a provided model. This is a streaming endpoint, so there will be a series of responses. The final response object will include statistics and additional data from the request. + +### Parameters + +- `model`: (required) the [model name](#model-names) +- `prompt`: the prompt to generate a response for +- `suffix`: the text after the model response +- `images`: (optional) a list of base64-encoded images (for multimodal models such as `llava`) + +Advanced parameters (optional): + +- `format`: the format to return a response in. Format can be `json` or a JSON schema +- `options`: additional model parameters listed in the documentation for the [Modelfile](./modelfile.md#valid-parameters-and-values) such as `temperature` +- `system`: system message to (overrides what is defined in the `Modelfile`) +- `template`: the prompt template to use (overrides what is defined in the `Modelfile`) +- `stream`: if `false` the response will be returned as a single response object, rather than a stream of objects +- `raw`: if `true` no formatting will be applied to the prompt. You may choose to use the `raw` parameter if you are specifying a full templated prompt in your request to the API +- `keep_alive`: controls how long the model will stay loaded into memory following the request (default: `5m`) +- `context` (deprecated): the context parameter returned from a previous request to `/generate`, this can be used to keep a short conversational memory + +#### Structured outputs + +Structured outputs are supported by providing a JSON schema in the `format` parameter. The model will generate a response that matches the schema. See the [structured outputs](#request-structured-outputs) example below. + +#### JSON mode + +Enable JSON mode by setting the `format` parameter to `json`. This will structure the response as a valid JSON object. See the JSON mode [example](#request-json-mode) below. + +> [!IMPORTANT] +> It's important to instruct the model to use JSON in the `prompt`. Otherwise, the model may generate large amounts whitespace. + +### Examples + +#### Generate request (Streaming) + +##### Request + +```shell +curl http://localhost:11434/api/generate -d '{ + "model": "llama3.2", + "prompt": "Why is the sky blue?" +}' +``` + +##### Response + +A stream of JSON objects is returned: + +```json +{ + "model": "llama3.2", + "created_at": "2023-08-04T08:52:19.385406455-07:00", + "response": "The", + "done": false +} +``` + +The final response in the stream also includes additional data about the generation: + +- `total_duration`: time spent generating the response +- `load_duration`: time spent in nanoseconds loading the model +- `prompt_eval_count`: number of tokens in the prompt +- `prompt_eval_duration`: time spent in nanoseconds evaluating the prompt +- `eval_count`: number of tokens in the response +- `eval_duration`: time in nanoseconds spent generating the response +- `context`: an encoding of the conversation used in this response, this can be sent in the next request to keep a conversational memory +- `response`: empty if the response was streamed, if not streamed, this will contain the full response + +To calculate how fast the response is generated in tokens per second (token/s), divide `eval_count` / `eval_duration` * `10^9`. + +```json +{ + "model": "llama3.2", + "created_at": "2023-08-04T19:22:45.499127Z", + "response": "", + "done": true, + "context": [1, 2, 3], + "total_duration": 10706818083, + "load_duration": 6338219291, + "prompt_eval_count": 26, + "prompt_eval_duration": 130079000, + "eval_count": 259, + "eval_duration": 4232710000 +} +``` + +#### Request (No streaming) + +##### Request + +A response can be received in one reply when streaming is off. + +```shell +curl http://localhost:11434/api/generate -d '{ + "model": "llama3.2", + "prompt": "Why is the sky blue?", + "stream": false +}' +``` + +##### Response + +If `stream` is set to `false`, the response will be a single JSON object: + +```json +{ + "model": "llama3.2", + "created_at": "2023-08-04T19:22:45.499127Z", + "response": "The sky is blue because it is the color of the sky.", + "done": true, + "context": [1, 2, 3], + "total_duration": 5043500667, + "load_duration": 5025959, + "prompt_eval_count": 26, + "prompt_eval_duration": 325953000, + "eval_count": 290, + "eval_duration": 4709213000 +} +``` + +#### Request (with suffix) + +##### Request + +```shell +curl http://localhost:11434/api/generate -d '{ + "model": "codellama:code", + "prompt": "def compute_gcd(a, b):", + "suffix": " return result", + "options": { + "temperature": 0 + }, + "stream": false +}' +``` + +##### Response + +```json +{ + "model": "codellama:code", + "created_at": "2024-07-22T20:47:51.147561Z", + "response": "\n if a == 0:\n return b\n else:\n return compute_gcd(b % a, a)\n\ndef compute_lcm(a, b):\n result = (a * b) / compute_gcd(a, b)\n", + "done": true, + "done_reason": "stop", + "context": [...], + "total_duration": 1162761250, + "load_duration": 6683708, + "prompt_eval_count": 17, + "prompt_eval_duration": 201222000, + "eval_count": 63, + "eval_duration": 953997000 +} +``` + +#### Request (Structured outputs) + +##### Request + +```shell +curl -X POST http://localhost:11434/api/generate -H "Content-Type: application/json" -d '{ + "model": "llama3.1:8b", + "prompt": "Ollama is 22 years old and is busy saving the world. Respond using JSON", + "stream": false, + "format": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "available": { + "type": "boolean" + } + }, + "required": [ + "age", + "available" + ] + } +}' +``` + +##### Response + +```json +{ + "model": "llama3.1:8b", + "created_at": "2024-12-06T00:48:09.983619Z", + "response": "{\n \"age\": 22,\n \"available\": true\n}", + "done": true, + "done_reason": "stop", + "context": [1, 2, 3], + "total_duration": 1075509083, + "load_duration": 567678166, + "prompt_eval_count": 28, + "prompt_eval_duration": 236000000, + "eval_count": 16, + "eval_duration": 269000000 +} +``` + +#### Request (JSON mode) + +> [!IMPORTANT] +> When `format` is set to `json`, the output will always be a well-formed JSON object. It's important to also instruct the model to respond in JSON. + +##### Request + +```shell +curl http://localhost:11434/api/generate -d '{ + "model": "llama3.2", + "prompt": "What color is the sky at different times of the day? Respond using JSON", + "format": "json", + "stream": false +}' +``` + +##### Response + +```json +{ + "model": "llama3.2", + "created_at": "2023-11-09T21:07:55.186497Z", + "response": "{\n\"morning\": {\n\"color\": \"blue\"\n},\n\"noon\": {\n\"color\": \"blue-gray\"\n},\n\"afternoon\": {\n\"color\": \"warm gray\"\n},\n\"evening\": {\n\"color\": \"orange\"\n}\n}\n", + "done": true, + "context": [1, 2, 3], + "total_duration": 4648158584, + "load_duration": 4071084, + "prompt_eval_count": 36, + "prompt_eval_duration": 439038000, + "eval_count": 180, + "eval_duration": 4196918000 +} +``` + +The value of `response` will be a string containing JSON similar to: + +```json +{ + "morning": { + "color": "blue" + }, + "noon": { + "color": "blue-gray" + }, + "afternoon": { + "color": "warm gray" + }, + "evening": { + "color": "orange" + } +} +``` + +#### Request (with images) + +To submit images to multimodal models such as `llava` or `bakllava`, provide a list of base64-encoded `images`: + +#### Request + +```shell +curl http://localhost:11434/api/generate -d '{ + "model": "llava", + "prompt":"What is in this picture?", + "stream": false, + "images": ["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"] +}' +``` + +#### Response + +```json +{ + "model": "llava", + "created_at": "2023-11-03T15:36:02.583064Z", + "response": "A happy cartoon character, which is cute and cheerful.", + "done": true, + "context": [1, 2, 3], + "total_duration": 2938432250, + "load_duration": 2559292, + "prompt_eval_count": 1, + "prompt_eval_duration": 2195557000, + "eval_count": 44, + "eval_duration": 736432000 +} +``` + +#### Request (Raw Mode) + +In some cases, you may wish to bypass the templating system and provide a full prompt. In this case, you can use the `raw` parameter to disable templating. Also note that raw mode will not return a context. + +##### Request + +```shell +curl http://localhost:11434/api/generate -d '{ + "model": "mistral", + "prompt": "[INST] why is the sky blue? [/INST]", + "raw": true, + "stream": false +}' +``` + +#### Request (Reproducible outputs) + +For reproducible outputs, set `seed` to a number: + +##### Request + +```shell +curl http://localhost:11434/api/generate -d '{ + "model": "mistral", + "prompt": "Why is the sky blue?", + "options": { + "seed": 123 + } +}' +``` + +##### Response + +```json +{ + "model": "mistral", + "created_at": "2023-11-03T15:36:02.583064Z", + "response": " The sky appears blue because of a phenomenon called Rayleigh scattering.", + "done": true, + "total_duration": 8493852375, + "load_duration": 6589624375, + "prompt_eval_count": 14, + "prompt_eval_duration": 119039000, + "eval_count": 110, + "eval_duration": 1779061000 +} +``` + +#### Generate request (With options) + +If you want to set custom options for the model at runtime rather than in the Modelfile, you can do so with the `options` parameter. This example sets every available option, but you can set any of them individually and omit the ones you do not want to override. + +##### Request + +```shell +curl http://localhost:11434/api/generate -d '{ + "model": "llama3.2", + "prompt": "Why is the sky blue?", + "stream": false, + "options": { + "num_keep": 5, + "seed": 42, + "num_predict": 100, + "top_k": 20, + "top_p": 0.9, + "min_p": 0.0, + "typical_p": 0.7, + "repeat_last_n": 33, + "temperature": 0.8, + "repeat_penalty": 1.2, + "presence_penalty": 1.5, + "frequency_penalty": 1.0, + "mirostat": 1, + "mirostat_tau": 0.8, + "mirostat_eta": 0.6, + "penalize_newline": true, + "stop": ["\n", "user:"], + "numa": false, + "num_ctx": 1024, + "num_batch": 2, + "num_gpu": 1, + "main_gpu": 0, + "low_vram": false, + "vocab_only": false, + "use_mmap": true, + "use_mlock": false, + "num_thread": 8 + } +}' +``` + +##### Response + +```json +{ + "model": "llama3.2", + "created_at": "2023-08-04T19:22:45.499127Z", + "response": "The sky is blue because it is the color of the sky.", + "done": true, + "context": [1, 2, 3], + "total_duration": 4935886791, + "load_duration": 534986708, + "prompt_eval_count": 26, + "prompt_eval_duration": 107345000, + "eval_count": 237, + "eval_duration": 4289432000 +} +``` + +#### Load a model + +If an empty prompt is provided, the model will be loaded into memory. + +##### Request + +```shell +curl http://localhost:11434/api/generate -d '{ + "model": "llama3.2" +}' +``` + +##### Response + +A single JSON object is returned: + +```json +{ + "model": "llama3.2", + "created_at": "2023-12-18T19:52:07.071755Z", + "response": "", + "done": true +} +``` + +#### Unload a model + +If an empty prompt is provided and the `keep_alive` parameter is set to `0`, a model will be unloaded from memory. + +##### Request + +```shell +curl http://localhost:11434/api/generate -d '{ + "model": "llama3.2", + "keep_alive": 0 +}' +``` + +##### Response + +A single JSON object is returned: + +```json +{ + "model": "llama3.2", + "created_at": "2024-09-12T03:54:03.516566Z", + "response": "", + "done": true, + "done_reason": "unload" +} +``` + +## Generate a chat completion + +``` +POST /api/chat +``` + +Generate the next message in a chat with a provided model. This is a streaming endpoint, so there will be a series of responses. Streaming can be disabled using `"stream": false`. The final response object will include statistics and additional data from the request. + +### Parameters + +- `model`: (required) the [model name](#model-names) +- `messages`: the messages of the chat, this can be used to keep a chat memory +- `tools`: list of tools in JSON for the model to use if supported + +The `message` object has the following fields: + +- `role`: the role of the message, either `system`, `user`, `assistant`, or `tool` +- `content`: the content of the message +- `images` (optional): a list of images to include in the message (for multimodal models such as `llava`) +- `tool_calls` (optional): a list of tools in JSON that the model wants to use + +Advanced parameters (optional): + +- `format`: the format to return a response in. Format can be `json` or a JSON schema. +- `options`: additional model parameters listed in the documentation for the [Modelfile](./modelfile.md#valid-parameters-and-values) such as `temperature` +- `stream`: if `false` the response will be returned as a single response object, rather than a stream of objects +- `keep_alive`: controls how long the model will stay loaded into memory following the request (default: `5m`) + +### Structured outputs + +Structured outputs are supported by providing a JSON schema in the `format` parameter. The model will generate a response that matches the schema. See the [Chat request (Structured outputs)](#chat-request-structured-outputs) example below. + +### Examples + +#### Chat Request (Streaming) + +##### Request + +Send a chat message with a streaming response. + +```shell +curl http://localhost:11434/api/chat -d '{ + "model": "llama3.2", + "messages": [ + { + "role": "user", + "content": "why is the sky blue?" + } + ] +}' +``` + +##### Response + +A stream of JSON objects is returned: + +```json +{ + "model": "llama3.2", + "created_at": "2023-08-04T08:52:19.385406455-07:00", + "message": { + "role": "assistant", + "content": "The", + "images": null + }, + "done": false +} +``` + +Final response: + +```json +{ + "model": "llama3.2", + "created_at": "2023-08-04T19:22:45.499127Z", + "done": true, + "total_duration": 4883583458, + "load_duration": 1334875, + "prompt_eval_count": 26, + "prompt_eval_duration": 342546000, + "eval_count": 282, + "eval_duration": 4535599000 +} +``` + +#### Chat request (No streaming) + +##### Request + +```shell +curl http://localhost:11434/api/chat -d '{ + "model": "llama3.2", + "messages": [ + { + "role": "user", + "content": "why is the sky blue?" + } + ], + "stream": false +}' +``` + +##### Response + +```json +{ + "model": "llama3.2", + "created_at": "2023-12-12T14:13:43.416799Z", + "message": { + "role": "assistant", + "content": "Hello! How are you today?" + }, + "done": true, + "total_duration": 5191566416, + "load_duration": 2154458, + "prompt_eval_count": 26, + "prompt_eval_duration": 383809000, + "eval_count": 298, + "eval_duration": 4799921000 +} +``` + +#### Chat request (Structured outputs) + +##### Request + +```shell +curl -X POST http://localhost:11434/api/chat -H "Content-Type: application/json" -d '{ + "model": "llama3.1", + "messages": [{"role": "user", "content": "Ollama is 22 years old and busy saving the world. Return a JSON object with the age and availability."}], + "stream": false, + "format": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "available": { + "type": "boolean" + } + }, + "required": [ + "age", + "available" + ] + }, + "options": { + "temperature": 0 + } +}' +``` + +##### Response + +```json +{ + "model": "llama3.1", + "created_at": "2024-12-06T00:46:58.265747Z", + "message": { "role": "assistant", "content": "{\"age\": 22, \"available\": false}" }, + "done_reason": "stop", + "done": true, + "total_duration": 2254970291, + "load_duration": 574751416, + "prompt_eval_count": 34, + "prompt_eval_duration": 1502000000, + "eval_count": 12, + "eval_duration": 175000000 +} +``` + +#### Chat request (With History) + +Send a chat message with a conversation history. You can use this same approach to start the conversation using multi-shot or chain-of-thought prompting. + +##### Request + +```shell +curl http://localhost:11434/api/chat -d '{ + "model": "llama3.2", + "messages": [ + { + "role": "user", + "content": "why is the sky blue?" + }, + { + "role": "assistant", + "content": "due to rayleigh scattering." + }, + { + "role": "user", + "content": "how is that different than mie scattering?" + } + ] +}' +``` + +##### Response + +A stream of JSON objects is returned: + +```json +{ + "model": "llama3.2", + "created_at": "2023-08-04T08:52:19.385406455-07:00", + "message": { + "role": "assistant", + "content": "The" + }, + "done": false +} +``` + +Final response: + +```json +{ + "model": "llama3.2", + "created_at": "2023-08-04T19:22:45.499127Z", + "done": true, + "total_duration": 8113331500, + "load_duration": 6396458, + "prompt_eval_count": 61, + "prompt_eval_duration": 398801000, + "eval_count": 468, + "eval_duration": 7701267000 +} +``` + +#### Chat request (with images) + +##### Request + +Send a chat message with images. The images should be provided as an array, with the individual images encoded in Base64. + +```shell +curl http://localhost:11434/api/chat -d '{ + "model": "llava", + "messages": [ + { + "role": "user", + "content": "what is in this image?", + "images": ["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"] + } + ] +}' +``` + +##### Response + +```json +{ + "model": "llava", + "created_at": "2023-12-13T22:42:50.203334Z", + "message": { + "role": "assistant", + "content": " The image features a cute, little pig with an angry facial expression. It's wearing a heart on its shirt and is waving in the air. This scene appears to be part of a drawing or sketching project.", + "images": null + }, + "done": true, + "total_duration": 1668506709, + "load_duration": 1986209, + "prompt_eval_count": 26, + "prompt_eval_duration": 359682000, + "eval_count": 83, + "eval_duration": 1303285000 +} +``` + +#### Chat request (Reproducible outputs) + +##### Request + +```shell +curl http://localhost:11434/api/chat -d '{ + "model": "llama3.2", + "messages": [ + { + "role": "user", + "content": "Hello!" + } + ], + "options": { + "seed": 101, + "temperature": 0 + } +}' +``` + +##### Response + +```json +{ + "model": "llama3.2", + "created_at": "2023-12-12T14:13:43.416799Z", + "message": { + "role": "assistant", + "content": "Hello! How are you today?" + }, + "done": true, + "total_duration": 5191566416, + "load_duration": 2154458, + "prompt_eval_count": 26, + "prompt_eval_duration": 383809000, + "eval_count": 298, + "eval_duration": 4799921000 +} +``` + +#### Chat request (with tools) + +##### Request + +```shell +curl http://localhost:11434/api/chat -d '{ + "model": "llama3.2", + "messages": [ + { + "role": "user", + "content": "What is the weather today in Paris?" + } + ], + "stream": false, + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_weather", + "description": "Get the current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The location to get the weather for, e.g. San Francisco, CA" + }, + "format": { + "type": "string", + "description": "The format to return the weather in, e.g. 'celsius' or 'fahrenheit'", + "enum": ["celsius", "fahrenheit"] + } + }, + "required": ["location", "format"] + } + } + } + ] +}' +``` + +##### Response + +```json +{ + "model": "llama3.2", + "created_at": "2024-07-22T20:33:28.123648Z", + "message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "function": { + "name": "get_current_weather", + "arguments": { + "format": "celsius", + "location": "Paris, FR" + } + } + } + ] + }, + "done_reason": "stop", + "done": true, + "total_duration": 885095291, + "load_duration": 3753500, + "prompt_eval_count": 122, + "prompt_eval_duration": 328493000, + "eval_count": 33, + "eval_duration": 552222000 +} +``` + +#### Load a model + +If the messages array is empty, the model will be loaded into memory. + +##### Request + +```shell +curl http://localhost:11434/api/chat -d '{ + "model": "llama3.2", + "messages": [] +}' +``` + +##### Response + +```json +{ + "model": "llama3.2", + "created_at":"2024-09-12T21:17:29.110811Z", + "message": { + "role": "assistant", + "content": "" + }, + "done_reason": "load", + "done": true +} +``` + +#### Unload a model + +If the messages array is empty and the `keep_alive` parameter is set to `0`, a model will be unloaded from memory. + +##### Request + +```shell +curl http://localhost:11434/api/chat -d '{ + "model": "llama3.2", + "messages": [], + "keep_alive": 0 +}' +``` + +##### Response + +A single JSON object is returned: + +```json +{ + "model": "llama3.2", + "created_at":"2024-09-12T21:33:17.547535Z", + "message": { + "role": "assistant", + "content": "" + }, + "done_reason": "unload", + "done": true +} +``` + +## Create a Model + +``` +POST /api/create +``` + +Create a model from: + * another model; + * a safetensors directory; or + * a GGUF file. + +If you are creating a model from a safetensors directory or from a GGUF file, you must [create a blob](#create-a-blob) for each of the files and then use the file name and SHA256 digest associated with each blob in the `files` field. + +### Parameters + +- `model`: name of the model to create +- `from`: (optional) name of an existing model to create the new model from +- `files`: (optional) a dictionary of file names to SHA256 digests of blobs to create the model from +- `adapters`: (optional) a dictionary of file names to SHA256 digests of blobs for LORA adapters +- `template`: (optional) the prompt template for the model +- `license`: (optional) a string or list of strings containing the license or licenses for the model +- `system`: (optional) a string containing the system prompt for the model +- `parameters`: (optional) a dictionary of parameters for the model (see [Modelfile](./modelfile.md#valid-parameters-and-values) for a list of parameters) +- `messages`: (optional) a list of message objects used to create a conversation +- `stream`: (optional) if `false` the response will be returned as a single response object, rather than a stream of objects +- `quantize` (optional): quantize a non-quantized (e.g. float16) model + +#### Quantization types + +| Type | Recommended | +| --- | :-: | +| q2_K | | +| q3_K_L | | +| q3_K_M | | +| q3_K_S | | +| q4_0 | | +| q4_1 | | +| q4_K_M | * | +| q4_K_S | | +| q5_0 | | +| q5_1 | | +| q5_K_M | | +| q5_K_S | | +| q6_K | | +| q8_0 | * | + +### Examples + +#### Create a new model + +Create a new model from an existing model. + +##### Request + +```shell +curl http://localhost:11434/api/create -d '{ + "model": "mario", + "from": "llama3.2", + "system": "You are Mario from Super Mario Bros." +}' +``` + +##### Response + +A stream of JSON objects is returned: + +```json +{"status":"reading model metadata"} +{"status":"creating system layer"} +{"status":"using already created layer sha256:22f7f8ef5f4c791c1b03d7eb414399294764d7cc82c7e94aa81a1feb80a983a2"} +{"status":"using already created layer sha256:8c17c2ebb0ea011be9981cc3922db8ca8fa61e828c5d3f44cb6ae342bf80460b"} +{"status":"using already created layer sha256:7c23fb36d80141c4ab8cdbb61ee4790102ebd2bf7aeff414453177d4f2110e5d"} +{"status":"using already created layer sha256:2e0493f67d0c8c9c68a8aeacdf6a38a2151cb3c4c1d42accf296e19810527988"} +{"status":"using already created layer sha256:2759286baa875dc22de5394b4a925701b1896a7e3f8e53275c36f75a877a82c9"} +{"status":"writing layer sha256:df30045fe90f0d750db82a058109cecd6d4de9c90a3d75b19c09e5f64580bb42"} +{"status":"writing layer sha256:f18a68eb09bf925bb1b669490407c1b1251c5db98dc4d3d81f3088498ea55690"} +{"status":"writing manifest"} +{"status":"success"} +``` + +#### Quantize a model + +Quantize a non-quantized model. + +##### Request + +```shell +curl http://localhost:11434/api/create -d '{ + "model": "llama3.1:quantized", + "from": "llama3.1:8b-instruct-fp16", + "quantize": "q4_K_M" +}' +``` + +##### Response + +A stream of JSON objects is returned: + +```json +{"status":"quantizing F16 model to Q4_K_M"} +{"status":"creating new layer sha256:667b0c1932bc6ffc593ed1d03f895bf2dc8dc6df21db3042284a6f4416b06a29"} +{"status":"using existing layer sha256:11ce4ee3e170f6adebac9a991c22e22ab3f8530e154ee669954c4bc73061c258"} +{"status":"using existing layer sha256:0ba8f0e314b4264dfd19df045cde9d4c394a52474bf92ed6a3de22a4ca31a177"} +{"status":"using existing layer sha256:56bb8bd477a519ffa694fc449c2413c6f0e1d3b1c88fa7e3c9d88d3ae49d4dcb"} +{"status":"creating new layer sha256:455f34728c9b5dd3376378bfb809ee166c145b0b4c1f1a6feca069055066ef9a"} +{"status":"writing manifest"} +{"status":"success"} +``` + +#### Create a model from GGUF + +Create a model from a GGUF file. The `files` parameter should be filled out with the file name and SHA256 digest of the GGUF file you wish to use. Use [/api/blobs/:digest](#push-a-blob) to push the GGUF file to the server before calling this API. + + +##### Request + +```shell +curl http://localhost:11434/api/create -d '{ + "model": "my-gguf-model", + "files": { + "test.gguf": "sha256:432f310a77f4650a88d0fd59ecdd7cebed8d684bafea53cbff0473542964f0c3" + } +}' +``` + +##### Response + +A stream of JSON objects is returned: + +```json +{"status":"parsing GGUF"} +{"status":"using existing layer sha256:432f310a77f4650a88d0fd59ecdd7cebed8d684bafea53cbff0473542964f0c3"} +{"status":"writing manifest"} +{"status":"success"} +``` + + +#### Create a model from a Safetensors directory + +The `files` parameter should include a dictionary of files for the safetensors model which includes the file names and SHA256 digest of each file. Use [/api/blobs/:digest](#push-a-blob) to first push each of the files to the server before calling this API. Files will remain in the cache until the Ollama server is restarted. + +##### Request + +```shell +curl http://localhost:11434/api/create -d '{ + "model": "fred", + "files": { + "config.json": "sha256:dd3443e529fb2290423a0c65c2d633e67b419d273f170259e27297219828e389", + "generation_config.json": "sha256:88effbb63300dbbc7390143fbbdd9d9fa50587b37e8bfd16c8c90d4970a74a36", + "special_tokens_map.json": "sha256:b7455f0e8f00539108837bfa586c4fbf424e31f8717819a6798be74bef813d05", + "tokenizer.json": "sha256:bbc1904d35169c542dffbe1f7589a5994ec7426d9e5b609d07bab876f32e97ab", + "tokenizer_config.json": "sha256:24e8a6dc2547164b7002e3125f10b415105644fcf02bf9ad8b674c87b1eaaed6", + "model.safetensors": "sha256:1ff795ff6a07e6a68085d206fb84417da2f083f68391c2843cd2b8ac6df8538f" + } +}' +``` + +##### Response + +A stream of JSON objects is returned: + +```shell +{"status":"converting model"} +{"status":"creating new layer sha256:05ca5b813af4a53d2c2922933936e398958855c44ee534858fcfd830940618b6"} +{"status":"using autodetected template llama3-instruct"} +{"status":"using existing layer sha256:56bb8bd477a519ffa694fc449c2413c6f0e1d3b1c88fa7e3c9d88d3ae49d4dcb"} +{"status":"writing manifest"} +{"status":"success"} +``` + +## Check if a Blob Exists + +```shell +HEAD /api/blobs/:digest +``` + +Ensures that the file blob (Binary Large Object) used with create a model exists on the server. This checks your Ollama server and not ollama.com. + +### Query Parameters + +- `digest`: the SHA256 digest of the blob + +### Examples + +#### Request + +```shell +curl -I http://localhost:11434/api/blobs/sha256:29fdb92e57cf0827ded04ae6461b5931d01fa595843f55d36f5b275a52087dd2 +``` + +#### Response + +Return 200 OK if the blob exists, 404 Not Found if it does not. + +## Push a Blob + +``` +POST /api/blobs/:digest +``` + +Push a file to the Ollama server to create a "blob" (Binary Large Object). + +### Query Parameters + +- `digest`: the expected SHA256 digest of the file + +### Examples + +#### Request + +```shell +curl -T model.gguf -X POST http://localhost:11434/api/blobs/sha256:29fdb92e57cf0827ded04ae6461b5931d01fa595843f55d36f5b275a52087dd2 +``` + +#### Response + +Return 201 Created if the blob was successfully created, 400 Bad Request if the digest used is not expected. + +## List Local Models + +``` +GET /api/tags +``` + +List models that are available locally. + +### Examples + +#### Request + +```shell +curl http://localhost:11434/api/tags +``` + +#### Response + +A single JSON object will be returned. + +```json +{ + "models": [ + { + "name": "codellama:13b", + "modified_at": "2023-11-04T14:56:49.277302595-07:00", + "size": 7365960935, + "digest": "9f438cb9cd581fc025612d27f7c1a6669ff83a8bb0ed86c94fcf4c5440555697", + "details": { + "format": "gguf", + "family": "llama", + "families": null, + "parameter_size": "13B", + "quantization_level": "Q4_0" + } + }, + { + "name": "llama3:latest", + "modified_at": "2023-12-07T09:32:18.757212583-08:00", + "size": 3825819519, + "digest": "fe938a131f40e6f6d40083c9f0f430a515233eb2edaa6d72eb85c50d64f2300e", + "details": { + "format": "gguf", + "family": "llama", + "families": null, + "parameter_size": "7B", + "quantization_level": "Q4_0" + } + } + ] +} +``` + +## Show Model Information + +``` +POST /api/show +``` + +Show information about a model including details, modelfile, template, parameters, license, system prompt. + +### Parameters + +- `model`: name of the model to show +- `verbose`: (optional) if set to `true`, returns full data for verbose response fields + +### Examples + +#### Request + +```shell +curl http://localhost:11434/api/show -d '{ + "model": "llama3.2" +}' +``` + +#### Response + +```json +{ + "modelfile": "# Modelfile generated by \"ollama show\"\n# To build a new Modelfile based on this one, replace the FROM line with:\n# FROM llava:latest\n\nFROM /Users/matt/.ollama/models/blobs/sha256:200765e1283640ffbd013184bf496e261032fa75b99498a9613be4e94d63ad52\nTEMPLATE \"\"\"{{ .System }}\nUSER: {{ .Prompt }}\nASSISTANT: \"\"\"\nPARAMETER num_ctx 4096\nPARAMETER stop \"\u003c/s\u003e\"\nPARAMETER stop \"USER:\"\nPARAMETER stop \"ASSISTANT:\"", + "parameters": "num_keep 24\nstop \"<|start_header_id|>\"\nstop \"<|end_header_id|>\"\nstop \"<|eot_id|>\"", + "template": "{{ if .System }}<|start_header_id|>system<|end_header_id|>\n\n{{ .System }}<|eot_id|>{{ end }}{{ if .Prompt }}<|start_header_id|>user<|end_header_id|>\n\n{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|>\n\n{{ .Response }}<|eot_id|>", + "details": { + "parent_model": "", + "format": "gguf", + "family": "llama", + "families": [ + "llama" + ], + "parameter_size": "8.0B", + "quantization_level": "Q4_0" + }, + "model_info": { + "general.architecture": "llama", + "general.file_type": 2, + "general.parameter_count": 8030261248, + "general.quantization_version": 2, + "llama.attention.head_count": 32, + "llama.attention.head_count_kv": 8, + "llama.attention.layer_norm_rms_epsilon": 0.00001, + "llama.block_count": 32, + "llama.context_length": 8192, + "llama.embedding_length": 4096, + "llama.feed_forward_length": 14336, + "llama.rope.dimension_count": 128, + "llama.rope.freq_base": 500000, + "llama.vocab_size": 128256, + "tokenizer.ggml.bos_token_id": 128000, + "tokenizer.ggml.eos_token_id": 128009, + "tokenizer.ggml.merges": [], // populates if `verbose=true` + "tokenizer.ggml.model": "gpt2", + "tokenizer.ggml.pre": "llama-bpe", + "tokenizer.ggml.token_type": [], // populates if `verbose=true` + "tokenizer.ggml.tokens": [] // populates if `verbose=true` + } +} +``` + +## Copy a Model + +``` +POST /api/copy +``` + +Copy a model. Creates a model with another name from an existing model. + +### Examples + +#### Request + +```shell +curl http://localhost:11434/api/copy -d '{ + "source": "llama3.2", + "destination": "llama3-backup" +}' +``` + +#### Response + +Returns a 200 OK if successful, or a 404 Not Found if the source model doesn't exist. + +## Delete a Model + +``` +DELETE /api/delete +``` + +Delete a model and its data. + +### Parameters + +- `model`: model name to delete + +### Examples + +#### Request + +```shell +curl -X DELETE http://localhost:11434/api/delete -d '{ + "model": "llama3:13b" +}' +``` + +#### Response + +Returns a 200 OK if successful, 404 Not Found if the model to be deleted doesn't exist. + +## Pull a Model + +``` +POST /api/pull +``` + +Download a model from the ollama library. Cancelled pulls are resumed from where they left off, and multiple calls will share the same download progress. + +### Parameters + +- `model`: name of the model to pull +- `insecure`: (optional) allow insecure connections to the library. Only use this if you are pulling from your own library during development. +- `stream`: (optional) if `false` the response will be returned as a single response object, rather than a stream of objects + +### Examples + +#### Request + +```shell +curl http://localhost:11434/api/pull -d '{ + "model": "llama3.2" +}' +``` + +#### Response + +If `stream` is not specified, or set to `true`, a stream of JSON objects is returned: + +The first object is the manifest: + +```json +{ + "status": "pulling manifest" +} +``` + +Then there is a series of downloading responses. Until any of the download is completed, the `completed` key may not be included. The number of files to be downloaded depends on the number of layers specified in the manifest. + +```json +{ + "status": "downloading digestname", + "digest": "digestname", + "total": 2142590208, + "completed": 241970 +} +``` + +After all the files are downloaded, the final responses are: + +```json +{ + "status": "verifying sha256 digest" +} +{ + "status": "writing manifest" +} +{ + "status": "removing any unused layers" +} +{ + "status": "success" +} +``` + +if `stream` is set to false, then the response is a single JSON object: + +```json +{ + "status": "success" +} +``` + +## Push a Model + +``` +POST /api/push +``` + +Upload a model to a model library. Requires registering for ollama.ai and adding a public key first. + +### Parameters + +- `model`: name of the model to push in the form of `/:` +- `insecure`: (optional) allow insecure connections to the library. Only use this if you are pushing to your library during development. +- `stream`: (optional) if `false` the response will be returned as a single response object, rather than a stream of objects + +### Examples + +#### Request + +```shell +curl http://localhost:11434/api/push -d '{ + "model": "mattw/pygmalion:latest" +}' +``` + +#### Response + +If `stream` is not specified, or set to `true`, a stream of JSON objects is returned: + +```json +{ "status": "retrieving manifest" } +``` + +and then: + +```json +{ + "status": "starting upload", + "digest": "sha256:bc07c81de745696fdf5afca05e065818a8149fb0c77266fb584d9b2cba3711ab", + "total": 1928429856 +} +``` + +Then there is a series of uploading responses: + +```json +{ + "status": "starting upload", + "digest": "sha256:bc07c81de745696fdf5afca05e065818a8149fb0c77266fb584d9b2cba3711ab", + "total": 1928429856 +} +``` + +Finally, when the upload is complete: + +```json +{"status":"pushing manifest"} +{"status":"success"} +``` + +If `stream` is set to `false`, then the response is a single JSON object: + +```json +{ "status": "success" } +``` + +## Generate Embeddings + +``` +POST /api/embed +``` + +Generate embeddings from a model + +### Parameters + +- `model`: name of model to generate embeddings from +- `input`: text or list of text to generate embeddings for + +Advanced parameters: + +- `truncate`: truncates the end of each input to fit within context length. Returns error if `false` and context length is exceeded. Defaults to `true` +- `options`: additional model parameters listed in the documentation for the [Modelfile](./modelfile.md#valid-parameters-and-values) such as `temperature` +- `keep_alive`: controls how long the model will stay loaded into memory following the request (default: `5m`) + +### Examples + +#### Request + +```shell +curl http://localhost:11434/api/embed -d '{ + "model": "all-minilm", + "input": "Why is the sky blue?" +}' +``` + +#### Response + +```json +{ + "model": "all-minilm", + "embeddings": [[ + 0.010071029, -0.0017594862, 0.05007221, 0.04692972, 0.054916814, + 0.008599704, 0.105441414, -0.025878139, 0.12958129, 0.031952348 + ]], + "total_duration": 14143917, + "load_duration": 1019500, + "prompt_eval_count": 8 +} +``` + +#### Request (Multiple input) + +```shell +curl http://localhost:11434/api/embed -d '{ + "model": "all-minilm", + "input": ["Why is the sky blue?", "Why is the grass green?"] +}' +``` + +#### Response + +```json +{ + "model": "all-minilm", + "embeddings": [[ + 0.010071029, -0.0017594862, 0.05007221, 0.04692972, 0.054916814, + 0.008599704, 0.105441414, -0.025878139, 0.12958129, 0.031952348 + ],[ + -0.0098027075, 0.06042469, 0.025257962, -0.006364387, 0.07272725, + 0.017194884, 0.09032035, -0.051705178, 0.09951512, 0.09072481 + ]] +} +``` + +## List Running Models +``` +GET /api/ps +``` + +List models that are currently loaded into memory. + +#### Examples + +### Request + +```shell +curl http://localhost:11434/api/ps +``` + +#### Response + +A single JSON object will be returned. + +```json +{ + "models": [ + { + "name": "mistral:latest", + "model": "mistral:latest", + "size": 5137025024, + "digest": "2ae6f6dd7a3dd734790bbbf58b8909a606e0e7e97e94b7604e0aa7ae4490e6d8", + "details": { + "parent_model": "", + "format": "gguf", + "family": "llama", + "families": [ + "llama" + ], + "parameter_size": "7.2B", + "quantization_level": "Q4_0" + }, + "expires_at": "2024-06-04T14:38:31.83753-07:00", + "size_vram": 5137025024 + } + ] +} +``` + +## Generate Embedding + +> Note: this endpoint has been superseded by `/api/embed` + +``` +POST /api/embeddings +``` + +Generate embeddings from a model + +### Parameters + +- `model`: name of model to generate embeddings from +- `prompt`: text to generate embeddings for + +Advanced parameters: + +- `options`: additional model parameters listed in the documentation for the [Modelfile](./modelfile.md#valid-parameters-and-values) such as `temperature` +- `keep_alive`: controls how long the model will stay loaded into memory following the request (default: `5m`) + +### Examples + +#### Request + +```shell +curl http://localhost:11434/api/embeddings -d '{ + "model": "all-minilm", + "prompt": "Here is an article about llamas..." +}' +``` + +#### Response + +```json +{ + "embedding": [ + 0.5670403838157654, 0.009260174818336964, 0.23178744316101074, -0.2916173040866852, -0.8924556970596313, + 0.8785552978515625, -0.34576427936553955, 0.5742510557174683, -0.04222835972905159, -0.137906014919281 + ] +} +``` + +## Version + +``` +GET /api/version +``` + +Retrieve the Ollama version + +### Examples + +#### Request + +```shell +curl http://localhost:11434/api/version +``` + +#### Response + +```json +{ + "version": "0.5.1" +} +``` + diff --git a/ollama_ai_integration/models/__init__.py b/ollama_ai_integration/models/__init__.py new file mode 100644 index 0000000..06829c9 --- /dev/null +++ b/ollama_ai_integration/models/__init__.py @@ -0,0 +1,4 @@ +from . import ollama_provider_mixin +from . import ai_provider_ollama +from . import ollama_instance +from . import ollama_model_stats diff --git a/ollama_ai_integration/models/ai_provider_ollama.py b/ollama_ai_integration/models/ai_provider_ollama.py new file mode 100644 index 0000000..3f6645e --- /dev/null +++ b/ollama_ai_integration/models/ai_provider_ollama.py @@ -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 [] diff --git a/ollama_ai_integration/models/ollama_model_stats.py b/ollama_ai_integration/models/ollama_model_stats.py new file mode 100644 index 0000000..d9f93df --- /dev/null +++ b/ollama_ai_integration/models/ollama_model_stats.py @@ -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)) + } + } diff --git a/ollama_ai_integration/models/ollama_provider.py b/ollama_ai_integration/models/ollama_provider.py new file mode 100644 index 0000000..c307cd2 --- /dev/null +++ b/ollama_ai_integration/models/ollama_provider.py @@ -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"{content}\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) + )) diff --git a/ollama_ai_integration/models/ollama_provider_mixin.py b/ollama_ai_integration/models/ollama_provider_mixin.py new file mode 100644 index 0000000..45d4a3b --- /dev/null +++ b/ollama_ai_integration/models/ollama_provider_mixin.py @@ -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 diff --git a/ollama_ai_integration/security/ir.model.access.csv b/ollama_ai_integration/security/ir.model.access.csv new file mode 100644 index 0000000..ad2ea39 --- /dev/null +++ b/ollama_ai_integration/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_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 diff --git a/ollama_ai_integration/views/ollama_stats_views.xml b/ollama_ai_integration/views/ollama_stats_views.xml new file mode 100644 index 0000000..7d67b49 --- /dev/null +++ b/ollama_ai_integration/views/ollama_stats_views.xml @@ -0,0 +1,118 @@ + + + + + ollama.model.stats.list + ollama.model.stats + + + + + + + + + + + + + + + + ollama.model.stats.form + ollama.model.stats + +
+ + + + + + + + + + + + + + + +
+
+
+ + + + ollama.model.stats.search + ollama.model.stats + + + + + + + + + + + + + + + + + + + + ollama.model.stats.graph + ollama.model.stats + + + + + + + + + + + + + ollama.model.stats.pivot + ollama.model.stats + + + + + + + + + + + + + + + Model Statistics + ollama.model.stats + list,form,graph,pivot + {'search_default_last_month': 1} + +

+ No statistics recorded yet +

+

+ Statistics will be automatically recorded as you use your AI models. +

+
+
+ + + +
diff --git a/ollama_ai_integration/views/ollama_views.xml b/ollama_ai_integration/views/ollama_views.xml new file mode 100644 index 0000000..15ff3f5 --- /dev/null +++ b/ollama_ai_integration/views/ollama_views.xml @@ -0,0 +1,105 @@ + + + + + ollama.instance.form + ai.provider.instance + form + primary + + + +
+ +
+
+ + + + + + + + + + + + + + + + + +
+
+ + + + ollama.instance.list + ai.provider.instance + list + + + + + + + + + + + + + + + ollama.instance.search + ai.provider.instance + primary + + + + + + + + + + + Ollama Instances + ai.provider.instance + list,form + [('provider_type', '=', 'ollama')] + {'default_provider_type': 'ollama'} + +

+ Create your first Ollama instance +

+

+ Configure Ollama instances to use local AI models in your Odoo instance. + Make sure you have Ollama installed and running on your server. +

+
+
+ + + +