"""Local LLM compat smoke test for the useful_life_predictor service. Auto-detects an LM Studio (port 1234) or Ollama (port 11434) server on host.docker.internal or localhost. Skips silently when no local LLM is reachable, so CI runs stay green. When a server is present, this exercises the real OpenAI-compatible adapter end-to-end against a local model — i.e. it catches prompt / JSON-parsing regressions that only show up with a non-mocked LLM. """ import socket from odoo.tests.common import TransactionCase from odoo.tests import tagged 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(): candidates = [ ('host.docker.internal', 1234, 'local-model'), ('host.docker.internal', 11434, 'llama3.1:8b'), ('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 TestLocalLLMUsefulLife(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_useful_life_with_local_llm(self): params = self.env['ir.config_parameter'].sudo() keys = [ 'fusion_accounting.openai_base_url', 'fusion_accounting.openai_model', 'fusion_accounting.openai_api_key', 'fusion_accounting.provider.asset_useful_life', ] prior = {k: params.get_param(k) for k in keys} params.set_param('fusion_accounting.openai_base_url', self.base_url) params.set_param('fusion_accounting.openai_model', self.model) params.set_param('fusion_accounting.openai_api_key', 'lm-studio') params.set_param('fusion_accounting.provider.asset_useful_life', 'openai') try: from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import ( predict_useful_life, ) result = predict_useful_life( self.env, description='Dell laptop', amount=2500, partner_name='Dell Canada', ) self.assertIn('useful_life_years', result) self.assertIn('depreciation_method', result) self.assertIsInstance(result['useful_life_years'], (int, float)) self.assertIn( result['depreciation_method'], ('straight_line', 'declining_balance', 'units_of_production'), ) finally: for k, v in prior.items(): if v is not None: params.set_param(k, v)