"""Local LLM compatibility test (LM Studio, Ollama, etc.). Skips if no local OpenAI-compatible LLM server is reachable. When one is running (LM Studio at :1234, Ollama at :11434), runs an end-to-end: 1. Configure ``ir.config_parameter`` to point at the local server. 2. Trigger ``engine.suggest_matches`` with the 'openai' provider. 3. Assert the call did not crash and produced at least one suggestion. The smoke is intentionally lenient: local models often emit malformed JSON, in which case ``confidence_scoring`` falls back to statistical-only ranking. We assert end-to-end happiness, not AI re-rank quality. """ import socket from odoo.tests.common import TransactionCase, tagged from . import _factories as f def _server_reachable(host, port, timeout=1.0): try: with socket.create_connection((host, port), timeout=timeout): return True except (OSError, socket.timeout): return False def _detect_local_llm(): """Return (base_url, model_name) tuple, or (None, None) if no server. Tries LM Studio (:1234) and Ollama (:11434) on both ``host.docker.internal`` (so the container can reach the host) and ``localhost`` (so a non-containerised run finds the same servers). """ candidates = ( ('host.docker.internal', 1234, 'local-model'), # LM Studio ('host.docker.internal', 11434, 'llama3.1:8b'), # Ollama ('localhost', 1234, 'local-model'), ('localhost', 11434, 'llama3.1:8b'), ) for host, port, default_model in candidates: if _server_reachable(host, port, timeout=0.5): return (f'http://{host}:{port}/v1', default_model) return (None, None) @tagged('post_install', '-at_install', 'local_llm') class TestLocalLLMCompat(TransactionCase): def setUp(self): super().setUp() self.base_url, self.model = _detect_local_llm() if not self.base_url: self.skipTest( "No local LLM server detected " "(LM Studio :1234 / Ollama :11434)") def test_suggest_matches_with_local_llm(self): params = self.env['ir.config_parameter'].sudo() prior = { 'fusion_accounting.openai_base_url': params.get_param( 'fusion_accounting.openai_base_url'), 'fusion_accounting.openai_model': params.get_param( 'fusion_accounting.openai_model'), 'fusion_accounting.openai_api_key': params.get_param( 'fusion_accounting.openai_api_key'), 'fusion_accounting.provider.bank_rec_suggest': params.get_param( 'fusion_accounting.provider.bank_rec_suggest'), } params.set_param('fusion_accounting.openai_base_url', self.base_url) params.set_param('fusion_accounting.openai_model', self.model) # Local servers ignore the key but the adapter requires *some* value. params.set_param('fusion_accounting.openai_api_key', 'lm-studio') params.set_param( 'fusion_accounting.provider.bank_rec_suggest', 'openai') try: partner = self.env['res.partner'].create( {'name': 'Local LLM Partner'}) f.make_invoice(self.env, partner=partner, amount=750) bank_line = f.make_bank_line( self.env, amount=750, partner=partner, memo='REF 12345 Local LLM test') result = self.env['fusion.reconcile.engine'].suggest_matches( bank_line, limit_per_line=3) self.assertIn(bank_line.id, result) suggestions = self.env['fusion.reconcile.suggestion'].search([ ('statement_line_id', '=', bank_line.id), ]) self.assertGreater( len(suggestions), 0, "Local LLM run should still produce at least one suggestion " "(statistical fallback if AI re-rank fails)") finally: for key, value in prior.items(): if value is not None: params.set_param(key, value)