Restructure project files to follow the addon layout: - Move models to addons/itsulu_blog_publisher/models/ - Move services (LLM providers, routers) to addons/itsulu_blog_publisher/services/ - Move wizards to addons/itsulu_blog_publisher/wizards/ - Move views (XML templates) to addons/itsulu_blog_publisher/views/ - Move data (cron, mail templates) to addons/itsulu_blog_publisher/data/ - Move security (ACL) to addons/itsulu_blog_publisher/security/ - Move tests and factories to addons/itsulu_blog_publisher/tests/ - Move BDD features to addons/itsulu_blog_publisher/features/ - Create __init__.py files for all Python packages This enables proper Odoo module discovery and import structure. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
182 lines
7.7 KiB
Python
182 lines
7.7 KiB
Python
# addons/itsulu_blog_publisher/tests/test_llm_router.py
|
|
"""
|
|
Tests for services/llm_router.py
|
|
Behaviour: route LLM calls to the correct provider, record token usage,
|
|
raise UserError on unknown or misconfigured providers.
|
|
|
|
RED PHASE — all tests here FAIL until llm_router and provider services exist.
|
|
"""
|
|
from unittest.mock import patch, MagicMock
|
|
from odoo.tests import TransactionCase, tagged
|
|
from odoo.exceptions import UserError
|
|
from .factories import BlogPublisherFactory
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'itsulu_blog_publisher', 'llm_router')
|
|
class TestLLMRouterProviderDispatch(TransactionCase):
|
|
"""Verify that the LLM router dispatches to the correct backend provider."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
cls.factory = BlogPublisherFactory(cls.env)
|
|
# Store a known-valid stub API key in ir.config_parameter
|
|
cls.env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
|
|
)
|
|
cls.env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.openai_api_key', 'sk-openai-test-key'
|
|
)
|
|
cls.env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.gemini_api_key', 'gemini-test-key'
|
|
)
|
|
cls.env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.ollama_base_url', 'http://localhost:11434'
|
|
)
|
|
|
|
# --- Provider dispatch ---
|
|
|
|
def test_anthropic_provider_calls_anthropic_endpoint(self):
|
|
"""
|
|
1. Behaviour: router with provider='anthropic' calls AnthropicProvider.generate()
|
|
2. ARRANGE: router instantiated with provider='anthropic'
|
|
3. ACT: router.generate(prompt='Write a blog post about AI')
|
|
4. ASSERT: AnthropicProvider.generate was called once; result is non-empty string
|
|
5. FAIL reason: LLMRouter class does not exist yet
|
|
"""
|
|
# ARRANGE
|
|
from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter
|
|
mock_response = MagicMock()
|
|
mock_response.text = '<h1>AI Blog Post</h1><p>Content here...</p>'
|
|
mock_response.tokens_used = 800
|
|
|
|
with patch(
|
|
'odoo.addons.itsulu_blog_publisher.services.anthropic_provider'
|
|
'.AnthropicProvider.generate',
|
|
return_value=mock_response,
|
|
) as mock_generate:
|
|
# ACT
|
|
router = LLMRouter(self.env, provider='anthropic', model='claude-sonnet-4-20250514')
|
|
result = router.generate(prompt='Write a blog post about AI trends')
|
|
|
|
# ASSERT
|
|
mock_generate.assert_called_once()
|
|
self.assertTrue(result.text, "Router must return non-empty text from Anthropic")
|
|
|
|
def test_openai_provider_calls_openai_endpoint(self):
|
|
"""Router with provider='openai' delegates to OpenAIProvider.generate()."""
|
|
from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter
|
|
mock_response = MagicMock()
|
|
mock_response.text = '<p>OpenAI generated content</p>'
|
|
mock_response.tokens_used = 600
|
|
|
|
with patch(
|
|
'odoo.addons.itsulu_blog_publisher.services.openai_provider'
|
|
'.OpenAIProvider.generate',
|
|
return_value=mock_response,
|
|
) as mock_generate:
|
|
router = LLMRouter(self.env, provider='openai', model='gpt-4o')
|
|
result = router.generate(prompt='Write a blog post about cloud computing')
|
|
|
|
mock_generate.assert_called_once()
|
|
self.assertTrue(result.text)
|
|
|
|
def test_gemini_provider_calls_gemini_endpoint(self):
|
|
"""Router with provider='gemini' delegates to GeminiProvider.generate()."""
|
|
from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter
|
|
mock_response = MagicMock()
|
|
mock_response.text = '<p>Gemini generated content</p>'
|
|
mock_response.tokens_used = 700
|
|
|
|
with patch(
|
|
'odoo.addons.itsulu_blog_publisher.services.gemini_provider'
|
|
'.GeminiProvider.generate',
|
|
return_value=mock_response,
|
|
) as mock_generate:
|
|
router = LLMRouter(self.env, provider='gemini', model='gemini-2.0-flash')
|
|
result = router.generate(prompt='Write a blog post about automation')
|
|
|
|
mock_generate.assert_called_once()
|
|
self.assertTrue(result.text)
|
|
|
|
def test_ollama_provider_calls_local_api_endpoint(self):
|
|
"""Router with provider='ollama' delegates to OllamaProvider.generate()."""
|
|
from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter
|
|
mock_response = MagicMock()
|
|
mock_response.text = '<p>Mistral generated content</p>'
|
|
mock_response.tokens_used = 500
|
|
|
|
with patch(
|
|
'odoo.addons.itsulu_blog_publisher.services.ollama_provider'
|
|
'.OllamaProvider.generate',
|
|
return_value=mock_response,
|
|
) as mock_generate:
|
|
router = LLMRouter(self.env, provider='ollama', model='mistral')
|
|
result = router.generate(prompt='Write a blog post about open source AI')
|
|
|
|
mock_generate.assert_called_once()
|
|
self.assertTrue(result.text)
|
|
|
|
def test_unknown_provider_raises_user_error(self):
|
|
"""
|
|
Router with unrecognised provider must raise UserError immediately,
|
|
before any network call is made.
|
|
"""
|
|
from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter
|
|
|
|
with self.assertRaises(UserError) as ctx:
|
|
router = LLMRouter(self.env, provider='unknown_provider', model='some-model')
|
|
router.generate(prompt='This should fail')
|
|
|
|
self.assertIn('provider not configured', str(ctx.exception).lower())
|
|
|
|
def test_missing_api_key_raises_user_error(self):
|
|
"""Router raises UserError when required API key param is absent."""
|
|
from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter
|
|
# Remove the key
|
|
self.env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.anthropic_api_key', ''
|
|
)
|
|
with self.assertRaises(UserError) as ctx:
|
|
router = LLMRouter(self.env, provider='anthropic', model='claude-sonnet-4-20250514')
|
|
router.generate(prompt='Fail fast')
|
|
|
|
self.assertIn('api key', str(ctx.exception).lower())
|
|
# Restore for other tests
|
|
self.env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
|
|
)
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'itsulu_blog_publisher', 'llm_router')
|
|
class TestLLMRouterTokenLogging(TransactionCase):
|
|
"""Verify that token usage is captured from provider responses."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
cls.factory = BlogPublisherFactory(cls.env)
|
|
cls.env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
|
|
)
|
|
|
|
def test_router_response_includes_tokens_used(self):
|
|
"""
|
|
The LLMResponse object returned by router.generate() must expose
|
|
tokens_used as a positive integer when the API returns usage data.
|
|
"""
|
|
from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter
|
|
mock_response = MagicMock()
|
|
mock_response.text = '<p>Content</p>'
|
|
mock_response.tokens_used = 1234
|
|
|
|
with patch(
|
|
'odoo.addons.itsulu_blog_publisher.services.anthropic_provider'
|
|
'.AnthropicProvider.generate',
|
|
return_value=mock_response,
|
|
):
|
|
router = LLMRouter(self.env, provider='anthropic', model='claude-sonnet-4-20250514')
|
|
result = router.generate(prompt='Token test')
|
|
|
|
self.assertGreater(result.tokens_used, 0)
|
|
self.assertEqual(result.tokens_used, 1234)
|