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:
parent
0fc4febabf
commit
697b95a27b
8 changed files with 104 additions and 16 deletions
|
|
@ -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',
|
||||||
|
]
|
||||||
|
|
@ -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',
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
]
|
||||||
Loading…
Reference in a new issue