diff --git a/fusion_accounting_ai/__manifest__.py b/fusion_accounting_ai/__manifest__.py index ab54bffa..b52fcce5 100644 --- a/fusion_accounting_ai/__manifest__.py +++ b/fusion_accounting_ai/__manifest__.py @@ -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.', diff --git a/fusion_accounting_ai/services/adapters/__init__.py b/fusion_accounting_ai/services/adapters/__init__.py index 26807733..48898ded 100644 --- a/fusion_accounting_ai/services/adapters/__init__.py +++ b/fusion_accounting_ai/services/adapters/__init__.py @@ -1,2 +1,3 @@ from . import claude from . import openai_adapter +from ._base import LLMProvider diff --git a/fusion_accounting_ai/services/adapters/_base.py b/fusion_accounting_ai/services/adapters/_base.py new file mode 100644 index 00000000..3fe7d1ac --- /dev/null +++ b/fusion_accounting_ai/services/adapters/_base.py @@ -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.") diff --git a/fusion_accounting_ai/services/adapters/claude.py b/fusion_accounting_ai/services/adapters/claude.py index 70a76511..f49de153 100644 --- a/fusion_accounting_ai/services/adapters/claude.py +++ b/fusion_accounting_ai/services/adapters/claude.py @@ -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' diff --git a/fusion_accounting_ai/services/adapters/openai_adapter.py b/fusion_accounting_ai/services/adapters/openai_adapter.py index 8e791f6f..a3972e34 100644 --- a/fusion_accounting_ai/services/adapters/openai_adapter.py +++ b/fusion_accounting_ai/services/adapters/openai_adapter.py @@ -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' diff --git a/fusion_accounting_ai/tests/__init__.py b/fusion_accounting_ai/tests/__init__.py index e3410185..cf080727 100644 --- a/fusion_accounting_ai/tests/__init__.py +++ b/fusion_accounting_ai/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_post_migration from . import test_data_adapters +from . import test_llm_provider_contract diff --git a/fusion_accounting_ai/tests/test_llm_provider_contract.py b/fusion_accounting_ai/tests/test_llm_provider_contract.py new file mode 100644 index 00000000..ba67f619 --- /dev/null +++ b/fusion_accounting_ai/tests/test_llm_provider_contract.py @@ -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))