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
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting AI',
|
'name': 'Fusion Accounting AI',
|
||||||
'version': '19.0.1.0.0',
|
'version': '19.0.1.0.1',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'sequence': 26,
|
'sequence': 26,
|
||||||
'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.',
|
'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.',
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
from . import claude
|
from . import claude
|
||||||
from . import openai_adapter
|
from . import openai_adapter
|
||||||
|
from ._base import LLMProvider
|
||||||
|
|||||||
44
fusion_accounting_ai/services/adapters/_base.py
Normal file
44
fusion_accounting_ai/services/adapters/_base.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""LLMProvider contract - every adapter must conform.
|
||||||
|
|
||||||
|
Phase 1 generalisation: makes local LLM (Ollama, LM Studio, vLLM, llamafile,
|
||||||
|
llama.cpp HTTP server) a one-config-line drop-in via the OpenAI-compatible
|
||||||
|
HTTP API surface that all of them expose.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProvider:
|
||||||
|
"""Contract every LLM backend must satisfy. Adapters declare capabilities
|
||||||
|
as class attributes; the engine inspects them before calling optional methods."""
|
||||||
|
|
||||||
|
supports_tool_calling: bool = False
|
||||||
|
supports_streaming: bool = False
|
||||||
|
max_context_tokens: int = 4096
|
||||||
|
supports_embeddings: bool = False
|
||||||
|
|
||||||
|
def __init__(self, env):
|
||||||
|
self.env = env
|
||||||
|
|
||||||
|
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||||
|
"""Plain text completion. Required for ALL providers.
|
||||||
|
|
||||||
|
Returns: {'content': str, 'tokens_used': int, 'model': str}
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def complete_with_tools(self, *, system, messages, tools, max_tokens=2048) -> dict:
|
||||||
|
"""Tool-calling completion. Optional - caller checks supports_tool_calling first.
|
||||||
|
|
||||||
|
Returns: {'content': str, 'tool_calls': [{'name': str, 'arguments': dict}], ...}
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"{type(self).__name__} does not support tool-calling. "
|
||||||
|
f"Check supports_tool_calling before calling.")
|
||||||
|
|
||||||
|
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
"""Embeddings. Optional - caller checks supports_embeddings first.
|
||||||
|
|
||||||
|
Returns: list of float vectors, one per input text.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"{type(self).__name__} does not support embeddings. "
|
||||||
|
f"Check supports_embeddings before calling.")
|
||||||
@@ -4,6 +4,8 @@ import logging
|
|||||||
from odoo import models, api, _
|
from odoo import models, api, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
from ._base import LLMProvider
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -12,6 +14,64 @@ except ImportError:
|
|||||||
anthropic_sdk = None
|
anthropic_sdk = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeAdapter(LLMProvider):
|
||||||
|
"""Plain-Python LLMProvider implementation for Anthropic Claude.
|
||||||
|
|
||||||
|
Preserves all existing functionality (extended thinking, native tool_use
|
||||||
|
blocks) used by the Odoo AbstractModel-based adapter -- this class is
|
||||||
|
additive for the Phase 1 LLMProvider contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
supports_tool_calling = True
|
||||||
|
supports_streaming = True
|
||||||
|
max_context_tokens = 200000
|
||||||
|
supports_embeddings = False
|
||||||
|
|
||||||
|
def __init__(self, env):
|
||||||
|
super().__init__(env)
|
||||||
|
if anthropic_sdk is None:
|
||||||
|
raise UserError(_("The 'anthropic' Python package is not installed."))
|
||||||
|
ICP = env['ir.config_parameter'].sudo()
|
||||||
|
try:
|
||||||
|
api_key = env['fusion.api.service'].get_api_key(
|
||||||
|
provider_type='anthropic',
|
||||||
|
consumer='fusion_accounting',
|
||||||
|
feature='chat_with_tools',
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
api_key = ICP.get_param('fusion_accounting.anthropic_api_key', '')
|
||||||
|
if not api_key:
|
||||||
|
api_key = 'not-needed'
|
||||||
|
self.client = anthropic_sdk.Anthropic(api_key=api_key)
|
||||||
|
self.model = ICP.get_param(
|
||||||
|
'fusion_accounting.claude_model', 'claude-sonnet-4-6')
|
||||||
|
|
||||||
|
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||||
|
api_messages = [
|
||||||
|
m for m in messages if m.get('role') in ('user', 'assistant')
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
response = self.client.messages.create(
|
||||||
|
model=self.model,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=temperature,
|
||||||
|
system=system,
|
||||||
|
messages=api_messages,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("Claude complete error: %s", e)
|
||||||
|
raise UserError(_("Claude API error: %s", str(e)))
|
||||||
|
text_parts = [b.text for b in response.content if getattr(b, 'type', None) == 'text']
|
||||||
|
return {
|
||||||
|
'content': '\n'.join(text_parts),
|
||||||
|
'tokens_used': (
|
||||||
|
getattr(response.usage, 'input_tokens', 0)
|
||||||
|
+ getattr(response.usage, 'output_tokens', 0)
|
||||||
|
),
|
||||||
|
'model': self.model,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class FusionAccountingAdapterClaude(models.AbstractModel):
|
class FusionAccountingAdapterClaude(models.AbstractModel):
|
||||||
_name = 'fusion.accounting.adapter.claude'
|
_name = 'fusion.accounting.adapter.claude'
|
||||||
_description = 'Claude AI Adapter'
|
_description = 'Claude AI Adapter'
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import logging
|
|||||||
from odoo import models, api, _
|
from odoo import models, api, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
from ._base import LLMProvider
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -12,6 +14,71 @@ except ImportError:
|
|||||||
OpenAI = None
|
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):
|
class FusionAccountingAdapterOpenAI(models.AbstractModel):
|
||||||
_name = 'fusion.accounting.adapter.openai'
|
_name = 'fusion.accounting.adapter.openai'
|
||||||
_description = 'OpenAI AI Adapter'
|
_description = 'OpenAI AI Adapter'
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
from . import test_post_migration
|
from . import test_post_migration
|
||||||
from . import test_data_adapters
|
from . import test_data_adapters
|
||||||
|
from . import test_llm_provider_contract
|
||||||
|
|||||||
45
fusion_accounting_ai/tests/test_llm_provider_contract.py
Normal file
45
fusion_accounting_ai/tests/test_llm_provider_contract.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters._base import LLMProvider
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestLLMProviderContract(TransactionCase):
|
||||||
|
"""Every LLM adapter must satisfy the LLMProvider contract."""
|
||||||
|
|
||||||
|
def test_base_class_defines_capability_attrs(self):
|
||||||
|
self.assertTrue(hasattr(LLMProvider, 'supports_tool_calling'))
|
||||||
|
self.assertTrue(hasattr(LLMProvider, 'supports_streaming'))
|
||||||
|
self.assertTrue(hasattr(LLMProvider, 'max_context_tokens'))
|
||||||
|
self.assertTrue(hasattr(LLMProvider, 'supports_embeddings'))
|
||||||
|
|
||||||
|
def test_openai_adapter_implements_contract(self):
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||||
|
self.assertTrue(issubclass(OpenAIAdapter, LLMProvider))
|
||||||
|
adapter = OpenAIAdapter(self.env)
|
||||||
|
self.assertIsInstance(adapter.supports_tool_calling, bool)
|
||||||
|
self.assertIsInstance(adapter.max_context_tokens, int)
|
||||||
|
|
||||||
|
def test_claude_adapter_implements_contract(self):
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||||
|
self.assertTrue(issubclass(ClaudeAdapter, LLMProvider))
|
||||||
|
adapter = ClaudeAdapter(self.env)
|
||||||
|
self.assertIsInstance(adapter.supports_tool_calling, bool)
|
||||||
|
self.assertIsInstance(adapter.max_context_tokens, int)
|
||||||
|
|
||||||
|
def test_openai_adapter_uses_configurable_base_url(self):
|
||||||
|
self.env['ir.config_parameter'].sudo().set_param(
|
||||||
|
'fusion_accounting.openai_base_url', 'http://localhost:1234/v1')
|
||||||
|
self.env['ir.config_parameter'].sudo().set_param(
|
||||||
|
'fusion_accounting.openai_api_key', 'lm-studio-test-key')
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||||
|
adapter = OpenAIAdapter(self.env)
|
||||||
|
self.assertEqual(str(adapter.client.base_url).rstrip('/'),
|
||||||
|
'http://localhost:1234/v1')
|
||||||
|
|
||||||
|
def test_openai_adapter_default_base_url_when_unset(self):
|
||||||
|
self.env['ir.config_parameter'].sudo().search([
|
||||||
|
('key', '=', 'fusion_accounting.openai_base_url')
|
||||||
|
]).unlink()
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||||
|
adapter = OpenAIAdapter(self.env)
|
||||||
|
self.assertIn('api.openai.com', str(adapter.client.base_url))
|
||||||
Reference in New Issue
Block a user