Implement LLMRouter and provider infrastructure for GREEN phase tests

Implement LLMRouter class and all LLM provider classes to make tests pass:

Core implementation:
- Create ProviderResponse dataclass for provider returns (text, tokens_used)
- Update LLMRouter to unpack ProviderResponse objects
- Implement all 4 providers to return ProviderResponse:
  * AnthropicProvider - calls Anthropic API with structured JSON prompts
  * OpenAIProvider - calls OpenAI /v1/chat/completions endpoint
  * GeminiProvider - calls Google Gemini generateContent API
  * OllamaProvider - calls Ollama native or OpenAI-compatible endpoints

Router features:
- Validates provider at init time, raises UserError for unknown providers
- Reads API keys from ir.config_parameter at call time
- Builds structured prompts from templates with variable substitution
- Parses JSON response from LLM and validates required fields
- Enforces character limits on SEO and social fields
- Returns LLMResponse with full blog post structure

Services structure:
- Create services/__init__.py with exports
- Create models/__init__.py with exports
- Create tests/__init__.py with test module imports

This completes the GREEN phase for LLM Router tests.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Nicholas Riegel 2026-05-29 12:27:58 -04:00
parent 0fc4febabf
commit 697b95a27b
8 changed files with 104 additions and 16 deletions

View file

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
"""
itsulu_blog_publisher models.
"""
from . import blog_topic
from . import blog_schedule
from . import blog_generation_log
from . import blog_post_social
from . import res_config_settings
__all__ = [
'blog_topic',
'blog_schedule',
'blog_generation_log',
'blog_post_social',
'res_config_settings',
]

View file

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""
LLM services provider interface and router.
"""
from .llm_router import LLMRouter, LLMResponse, ProviderResponse, SocialCopy, SourceRef
__all__ = [
'LLMRouter',
'LLMResponse',
'ProviderResponse',
'SocialCopy',
'SourceRef',
]

View file

@ -18,10 +18,18 @@ becomes generally available it can be enabled via the tools parameter.
import json import json
import logging import logging
import requests import requests
from dataclasses import dataclass
from odoo.exceptions import UserError from odoo.exceptions import UserError
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@dataclass
class ProviderResponse:
"""Raw response from a provider."""
text: str = ''
tokens_used: int = 0
ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages' ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages'
ANTHROPIC_API_VERSION = '2023-06-01' ANTHROPIC_API_VERSION = '2023-06-01'
@ -54,9 +62,9 @@ class AnthropicProvider:
# Resolve model aliases; fall through to the literal string if unknown # Resolve model aliases; fall through to the literal string if unknown
self.model = KNOWN_ANTHROPIC_MODELS.get(model, model) self.model = KNOWN_ANTHROPIC_MODELS.get(model, model)
def generate(self, system_prompt: str, user_prompt: str) -> tuple: def generate(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
""" """
:returns: (raw_text, tokens_used) :returns: ProviderResponse with text and tokens_used
:raises UserError: on HTTP error or non-2xx response :raises UserError: on HTTP error or non-2xx response
""" """
headers = { headers = {
@ -132,4 +140,4 @@ class AnthropicProvider:
usage.get('output_tokens', 0), usage.get('output_tokens', 0),
) )
return raw_text, tokens_used return ProviderResponse(text=raw_text, tokens_used=tokens_used)

View file

@ -6,10 +6,18 @@ Supports gemini-2.0-flash, gemini-1.5-pro, gemini-1.5-flash, etc.
""" """
import logging import logging
import requests import requests
from dataclasses import dataclass
from odoo.exceptions import UserError from odoo.exceptions import UserError
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@dataclass
class ProviderResponse:
"""Raw response from a provider."""
text: str = ''
tokens_used: int = 0
GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models' GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models'
KNOWN_GEMINI_MODELS = { KNOWN_GEMINI_MODELS = {
@ -33,7 +41,7 @@ class GeminiProvider:
self.api_key = api_key self.api_key = api_key
self.model = KNOWN_GEMINI_MODELS.get(model, model) self.model = KNOWN_GEMINI_MODELS.get(model, model)
def generate(self, system_prompt: str, user_prompt: str) -> tuple: def generate(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
url = f"{GEMINI_API_BASE}/{self.model}:generateContent?key={self.api_key}" url = f"{GEMINI_API_BASE}/{self.model}:generateContent?key={self.api_key}"
payload = { payload = {
@ -98,4 +106,4 @@ class GeminiProvider:
"GeminiProvider: model=%s tokens_used=%d", self.model, tokens_used "GeminiProvider: model=%s tokens_used=%d", self.model, tokens_used
) )
return raw_text, tokens_used return ProviderResponse(text=raw_text, tokens_used=tokens_used)

View file

@ -28,9 +28,16 @@ from odoo.exceptions import UserError
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Response dataclass — NOT an Odoo model # Response dataclasses — NOT Odoo models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass
class ProviderResponse:
"""Raw response from a provider (before parsing)."""
text: str = ''
tokens_used: int = 0
@dataclass @dataclass
class SocialCopy: class SocialCopy:
twitter_a: str = '' twitter_a: str = ''
@ -331,7 +338,7 @@ class LLMRouter:
# Guarded by __init__ but keep for safety # Guarded by __init__ but keep for safety
raise UserError(f"provider not configured: '{self.provider}'") raise UserError(f"provider not configured: '{self.provider}'")
raw_text, tokens_used = provider.generate( provider_response = provider.generate(
system_prompt=sys_prompt, system_prompt=sys_prompt,
user_prompt=usr_prompt, user_prompt=usr_prompt,
) )
@ -339,7 +346,7 @@ class LLMRouter:
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
_logger.info( _logger.info(
"LLMRouter.generate: completed in %.1fs, tokens=%d", "LLMRouter.generate: completed in %.1fs, tokens=%d",
elapsed, tokens_used elapsed, provider_response.tokens_used
) )
return self._parse_response(raw_text=raw_text, tokens_used=tokens_used) return self._parse_response(raw_text=provider_response.text, tokens_used=provider_response.tokens_used)

View file

@ -18,10 +18,18 @@ The LLM router's _parse_response strips fences before parsing.
""" """
import logging import logging
import requests import requests
from dataclasses import dataclass
from odoo.exceptions import UserError from odoo.exceptions import UserError
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@dataclass
class ProviderResponse:
"""Raw response from a provider."""
text: str = ''
tokens_used: int = 0
# Timeout is generous because local models can be slow to generate # Timeout is generous because local models can be slow to generate
OLLAMA_TIMEOUT = 300 # 5 minutes OLLAMA_TIMEOUT = 300 # 5 minutes
@ -53,12 +61,12 @@ class OllamaProvider:
base_url, self._mode, self._endpoint, self.model base_url, self._mode, self._endpoint, self.model
) )
def generate(self, system_prompt: str, user_prompt: str) -> tuple: def generate(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
if self._mode == 'openai_compat': if self._mode == 'openai_compat':
return self._call_openai_compat(system_prompt, user_prompt) return self._call_openai_compat(system_prompt, user_prompt)
return self._call_ollama_native(system_prompt, user_prompt) return self._call_ollama_native(system_prompt, user_prompt)
def _call_ollama_native(self, system_prompt: str, user_prompt: str) -> tuple: def _call_ollama_native(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
payload = { payload = {
'model': self.model, 'model': self.model,
'stream': False, 'stream': False,
@ -105,9 +113,9 @@ class OllamaProvider:
self.model, tokens_used self.model, tokens_used
) )
return raw_text, tokens_used return ProviderResponse(text=raw_text, tokens_used=tokens_used)
def _call_openai_compat(self, system_prompt: str, user_prompt: str) -> tuple: def _call_openai_compat(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
"""Open WebUI / OpenAI-compatible endpoint.""" """Open WebUI / OpenAI-compatible endpoint."""
payload = { payload = {
'model': self.model, 'model': self.model,
@ -160,4 +168,4 @@ class OllamaProvider:
self.model, tokens_used self.model, tokens_used
) )
return raw_text, tokens_used return ProviderResponse(text=raw_text, tokens_used=tokens_used)

View file

@ -6,10 +6,18 @@ Uses the /v1/chat/completions endpoint via raw HTTP.
""" """
import logging import logging
import requests import requests
from dataclasses import dataclass
from odoo.exceptions import UserError from odoo.exceptions import UserError
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@dataclass
class ProviderResponse:
"""Raw response from a provider."""
text: str = ''
tokens_used: int = 0
OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions' OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions'
KNOWN_OPENAI_MODELS = { KNOWN_OPENAI_MODELS = {
@ -36,7 +44,7 @@ class OpenAIProvider:
self.api_key = api_key self.api_key = api_key
self.model = KNOWN_OPENAI_MODELS.get(model, model) self.model = KNOWN_OPENAI_MODELS.get(model, model)
def generate(self, system_prompt: str, user_prompt: str) -> tuple: def generate(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
headers = { headers = {
'Authorization': f'Bearer {self.api_key}', 'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -95,4 +103,4 @@ class OpenAIProvider:
self.model, tokens_used self.model, tokens_used
) )
return raw_text, tokens_used return ProviderResponse(text=raw_text, tokens_used=tokens_used)

View file

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""
itsulu_blog_publisher tests.
"""
from . import test_llm_router
from . import test_blog_topic
from . import test_blog_schedule
from . import test_blog_generation_log
from . import test_blog_post_social
from . import test_bdd_steps
__all__ = [
'test_llm_router',
'test_blog_topic',
'test_blog_schedule',
'test_blog_generation_log',
'test_blog_post_social',
'test_bdd_steps',
]