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',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.0.1',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 26,
|
||||
'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.',
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import claude
|
||||
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.exceptions import UserError
|
||||
|
||||
from ._base import LLMProvider
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -12,6 +14,64 @@ except ImportError:
|
||||
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):
|
||||
_name = 'fusion.accounting.adapter.claude'
|
||||
_description = 'Claude AI Adapter'
|
||||
|
||||
@@ -4,6 +4,8 @@ import logging
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from ._base import LLMProvider
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -12,6 +14,71 @@ 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'
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import test_post_migration
|
||||
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