Files
Odoo-Modules/fusion_accounting_ai/services/adapters/openai_adapter.py
gsinghpal 123db4219f feat(fusion_accounting_ai): add LLMProvider contract + configurable openai base_url
Phase 1 prerequisite for local LLM support. Adapters now declare
capability flags (supports_tool_calling, max_context_tokens, etc.) so
the engine can reason about what backend is available.

OpenAI adapter accepts fusion_accounting.openai_base_url config -- point
it at LM Studio (http://host.docker.internal:1234/v1) or Ollama
(http://host.docker.internal:11434/v1) and the existing OpenAI adapter
works unchanged.

Implementation note: existing Odoo AbstractModel adapters
(fusion.accounting.adapter.openai/claude) are preserved untouched to
avoid breaking the chat panel; the new plain-Python OpenAIAdapter and
ClaudeAdapter classes (LLMProvider subclasses) are added alongside them.

Made-with: Cursor
2026-04-19 10:45:30 -04:00

205 lines
7.1 KiB
Python

import json
import logging
from odoo import models, api, _
from odoo.exceptions import UserError
from ._base import LLMProvider
_logger = logging.getLogger(__name__)
try:
from openai import OpenAI
except ImportError:
OpenAI = None
DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
class OpenAIAdapter(LLMProvider):
"""Plain-Python LLMProvider implementation backed by an OpenAI-compatible
HTTP endpoint.
The OpenAI Python SDK speaks to any server that exposes the OpenAI
Chat Completions surface: OpenAI itself, Ollama, LM Studio, vLLM,
llamafile, llama.cpp HTTP server, etc. Configure the endpoint via
the ``fusion_accounting.openai_base_url`` ir.config_parameter.
"""
supports_tool_calling = True
supports_streaming = True
max_context_tokens = 128000
supports_embeddings = True
def __init__(self, env):
super().__init__(env)
if OpenAI is None:
raise UserError(_("The 'openai' Python package is not installed."))
ICP = env['ir.config_parameter'].sudo()
base_url = ICP.get_param(
'fusion_accounting.openai_base_url', DEFAULT_OPENAI_BASE_URL,
) or DEFAULT_OPENAI_BASE_URL
try:
api_key = env['fusion.api.service'].get_api_key(
provider_type='openai',
consumer='fusion_accounting',
feature='chat_with_tools',
)
except Exception:
api_key = ICP.get_param('fusion_accounting.openai_api_key', '')
if not api_key:
# Local LLM servers (Ollama, LM Studio, llama.cpp) usually do not
# require a real key but the SDK insists on a non-empty string.
api_key = 'not-needed'
self.base_url = base_url
self.client = OpenAI(api_key=api_key, base_url=base_url)
self.model = ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini')
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
api_messages = [{'role': 'system', 'content': system}]
for msg in messages:
if msg.get('role') in ('user', 'assistant', 'tool'):
api_messages.append(msg)
try:
response = self.client.chat.completions.create(
model=self.model,
messages=api_messages,
max_tokens=max_tokens,
temperature=temperature,
)
except Exception as e:
_logger.error("OpenAI complete error: %s", e)
raise UserError(_("OpenAI API error: %s", str(e)))
choice = response.choices[0]
return {
'content': choice.message.content or '',
'tokens_used': getattr(response.usage, 'total_tokens', 0),
'model': self.model,
}
class FusionAccountingAdapterOpenAI(models.AbstractModel):
_name = 'fusion.accounting.adapter.openai'
_description = 'OpenAI AI Adapter'
def _get_client(self):
if OpenAI is None:
raise UserError(_("The 'openai' Python package is not installed."))
try:
key = self.env['fusion.api.service'].get_api_key(
provider_type='openai',
consumer='fusion_accounting',
feature='chat_with_tools',
)
except Exception:
ICP = self.env['ir.config_parameter'].sudo()
key = ICP.get_param('fusion_accounting.openai_api_key', '')
if not key:
raise UserError(_("No OpenAI API key configured."))
return OpenAI(api_key=key)
def _get_model_name(self):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini')
def _format_tools(self, tools):
formatted = []
for tool in tools:
formatted.append({
'type': 'function',
'function': {
'name': tool['name'],
'description': tool['description'],
'parameters': tool.get('parameters', {'type': 'object', 'properties': {}}),
},
})
return formatted
def _is_reasoning_model(self, model):
return model.startswith('o1') or model.startswith('o3') or model.startswith('o4')
def call_with_tools(self, system_prompt, messages, tools=None, model_override=None):
client = self._get_client()
model = model_override or self._get_model_name()
is_reasoning = self._is_reasoning_model(model)
if is_reasoning:
api_messages = [{'role': 'developer', 'content': system_prompt}]
else:
api_messages = [{'role': 'system', 'content': system_prompt}]
for msg in messages:
if msg['role'] in ('user', 'assistant', 'tool'):
api_messages.append(msg)
kwargs = {
'model': model,
'messages': api_messages,
}
if is_reasoning:
kwargs['max_completion_tokens'] = 16384
kwargs['reasoning_effort'] = 'medium'
else:
kwargs['max_tokens'] = 4096
if tools:
kwargs['tools'] = self._format_tools(tools)
try:
response = client.chat.completions.create(**kwargs)
except Exception as e:
_logger.error("OpenAI API error: %s", e)
raise UserError(_("OpenAI API error: %s", str(e)))
choice = response.choices[0]
message = choice.message
tool_calls = []
if message.tool_calls:
for tc in message.tool_calls:
try:
args = json.loads(tc.function.arguments)
except (json.JSONDecodeError, TypeError):
_logger.warning("Malformed tool args from OpenAI: %s", tc.function.arguments)
args = {}
tool_calls.append({
'id': tc.id,
'name': tc.function.name,
'arguments': args,
})
return {
'text': message.content or '',
'tool_calls': tool_calls if tool_calls else None,
'tokens_in': response.usage.prompt_tokens,
'tokens_out': response.usage.completion_tokens,
'stop_reason': choice.finish_reason,
'raw_message': message,
}
def append_tool_results(self, messages, ai_response, tool_results):
raw_msg = ai_response.get('raw_message')
assistant_msg = {'role': 'assistant', 'content': raw_msg.content or ''}
if raw_msg.tool_calls:
assistant_msg['tool_calls'] = [
{
'id': tc.id,
'type': 'function',
'function': {
'name': tc.function.name,
'arguments': tc.function.arguments,
},
}
for tc in raw_msg.tool_calls
]
messages.append(assistant_msg)
for tr in tool_results:
messages.append({
'role': 'tool',
'tool_call_id': tr['tool_call_id'],
'content': tr['result'],
})
return messages