# -*- coding: utf-8 -*- """ Extends res.config.settings with Blog Publisher configuration. All sensitive values (API keys, tokens) are stored in ir.config_parameter using Odoo's standard password widget — they are not displayed after save. """ from odoo import api, fields, models DEFAULT_SYSTEM_PROMPT = ( "You are an expert technology content writer for a professional AI and IT services company. " "You write clear, data-driven, SEO-optimised blog posts. You always cite real, verifiable " "sources with working URLs. You respond ONLY with valid JSON — no markdown fences, no " "preamble, no explanation outside the JSON object." ) class ResConfigSettings(models.TransientModel): _inherit = 'res.config.settings' # ------------------------------------------------------------------ # # LLM — Text providers # # ------------------------------------------------------------------ # blog_pub_default_provider = fields.Selection( selection=[ ('anthropic', 'Anthropic Claude'), ('openai', 'OpenAI ChatGPT'), ('gemini', 'Google Gemini'), ('ollama', 'Ollama / Open WebUI'), ], string='Default Text LLM Provider', config_parameter='itsulu_blog_publisher.default_provider', ) blog_pub_default_model = fields.Char( string='Default Text LLM Model', config_parameter='itsulu_blog_publisher.default_model', default='claude-sonnet-4-20250514', ) # Anthropic blog_pub_anthropic_api_key = fields.Char( string='Anthropic API Key (or Pro account token)', config_parameter='itsulu_blog_publisher.anthropic_api_key', help='Starts with sk-ant-api03- (API key) or claude.ai/api personal token. ' 'Never shared outside this server.', ) blog_pub_anthropic_model = fields.Selection( selection=[ ('claude-sonnet-4-20250514', 'Claude Sonnet 4 (Recommended)'), ('claude-opus-4-20250514', 'Claude Opus 4 (Best quality, slower)'), ('claude-haiku-4-5-20251001','Claude Haiku 4.5 (Fastest, lowest cost)'), ('claude-3-5-sonnet-20241022','Claude 3.5 Sonnet (Legacy)'), ('claude-3-opus-20240229', 'Claude 3 Opus (Legacy)'), ], string='Anthropic Default Model', config_parameter='itsulu_blog_publisher.anthropic_default_model', default='claude-sonnet-4-20250514', ) # OpenAI blog_pub_openai_api_key = fields.Char( string='OpenAI API Key', config_parameter='itsulu_blog_publisher.openai_api_key', help='Starts with sk-. Used for GPT-4o text generation and DALL·E 3 images.', ) blog_pub_openai_model = fields.Selection( selection=[ ('gpt-4o', 'GPT-4o (Recommended)'), ('gpt-4o-mini', 'GPT-4o Mini (Faster)'), ('o4-mini', 'o4-mini'), ('gpt-4-turbo', 'GPT-4 Turbo (Legacy)'), ], string='OpenAI Default Model', config_parameter='itsulu_blog_publisher.openai_default_model', default='gpt-4o', ) # Google Gemini blog_pub_gemini_api_key = fields.Char( string='Google Gemini API Key', config_parameter='itsulu_blog_publisher.gemini_api_key', ) blog_pub_gemini_model = fields.Selection( selection=[ ('gemini-2.0-flash', 'Gemini 2.0 Flash (Recommended)'), ('gemini-2.5-pro', 'Gemini 2.5 Pro (Best quality)'), ('gemini-2.5-flash', 'Gemini 2.5 Flash'), ('gemini-1.5-pro', 'Gemini 1.5 Pro (Legacy)'), ('gemini-1.5-flash', 'Gemini 1.5 Flash (Legacy)'), ], string='Gemini Default Model', config_parameter='itsulu_blog_publisher.gemini_default_model', default='gemini-2.0-flash', ) # ------------------------------------------------------------------ # # Ollama / Open WebUI # # ------------------------------------------------------------------ # blog_pub_ollama_base_url = fields.Char( string='Ollama / Open WebUI Base URL', config_parameter='itsulu_blog_publisher.ollama_base_url', help=( 'Examples:\n' ' Local Ollama: http://localhost:11434\n' ' Self-hosted Ollama: http://192.168.1.100:11434\n' ' Open WebUI (local): http://localhost:3000\n' ' Open WebUI (server): https://openwebui.yourcompany.com' ), ) blog_pub_ollama_model = fields.Char( string='Ollama Default Model', config_parameter='itsulu_blog_publisher.ollama_default_model', default='mistral', help='Must be pulled in Ollama first (ollama pull mistral).', ) # ------------------------------------------------------------------ # # Image generation # # ------------------------------------------------------------------ # blog_pub_default_image_provider = fields.Selection( selection=[ ('none', 'No Image Generation'), ('openai_dalle', 'OpenAI DALL·E 3'), ('google_imagen', 'Google Imagen'), ('stable_diff', 'Stable Diffusion (Ollama)'), ], string='Default Image Provider', config_parameter='itsulu_blog_publisher.default_image_provider', default='none', ) blog_pub_image_ollama_model = fields.Char( string='Ollama Image Model', config_parameter='itsulu_blog_publisher.image_ollama_model', default='stable-diffusion', help='Ollama model to use for image generation (e.g. stable-diffusion).', ) # ------------------------------------------------------------------ # # Default blog & content # # ------------------------------------------------------------------ # blog_pub_default_blog_id = fields.Many2one( comodel_name='blog.blog', string='Default Blog', compute='_compute_default_blog_id', inverse='_set_default_blog_id', help='Default blog for "Generate Now" when no slot is specified.', ) @api.depends() def _compute_default_blog_id(self): param = self.env['ir.config_parameter'].sudo().get_param( 'itsulu_blog_publisher.default_blog_id' ) blog_id = int(param) if param and param.isdigit() else False for rec in self: rec.blog_pub_default_blog_id = blog_id def _set_default_blog_id(self): for rec in self: self.env['ir.config_parameter'].sudo().set_param( 'itsulu_blog_publisher.default_blog_id', str(rec.blog_pub_default_blog_id.id) if rec.blog_pub_default_blog_id else '', ) blog_pub_default_tone = fields.Char( string='Default Tone', config_parameter='itsulu_blog_publisher.default_tone', default='professional and informative', ) # ------------------------------------------------------------------ # # Prompt templates # # ------------------------------------------------------------------ # blog_pub_system_prompt = fields.Text( string='System Prompt', compute='_compute_system_prompt', inverse='_set_system_prompt', help='Sent to the LLM as the system/persona instruction. ' 'Edit to tune writing style, persona, and output format.', ) @api.depends() def _compute_system_prompt(self): param = self.env['ir.config_parameter'].sudo().get_param( 'itsulu_blog_publisher.system_prompt', DEFAULT_SYSTEM_PROMPT ) for rec in self: rec.blog_pub_system_prompt = param or DEFAULT_SYSTEM_PROMPT def _set_system_prompt(self): for rec in self: self.env['ir.config_parameter'].sudo().set_param( 'itsulu_blog_publisher.system_prompt', rec.blog_pub_system_prompt or '', ) blog_pub_user_prompt_template = fields.Text( string='User Prompt Template', compute='_compute_user_prompt_template', inverse='_set_user_prompt_template', help='Template for the user message. Available variables: ' '{topic}, {blog_name}, {date}, {tone}, {enabled_platforms}.', ) @api.depends() def _compute_user_prompt_template(self): from ..services.llm_router import DEFAULT_USER_PROMPT_TEMPLATE param = self.env['ir.config_parameter'].sudo().get_param( 'itsulu_blog_publisher.user_prompt_template', '' ) for rec in self: rec.blog_pub_user_prompt_template = param or DEFAULT_USER_PROMPT_TEMPLATE def _set_user_prompt_template(self): for rec in self: self.env['ir.config_parameter'].sudo().set_param( 'itsulu_blog_publisher.user_prompt_template', rec.blog_pub_user_prompt_template or '', ) # ------------------------------------------------------------------ # # Notification email # # ------------------------------------------------------------------ # blog_pub_notification_emails = fields.Char( string='Notification Email Recipients', config_parameter='itsulu_blog_publisher.notification_emails', default='nicholasr@itsulu.com,sales@itsulu.com', help='Comma-separated. The user who triggers generation is always added automatically.', ) # ------------------------------------------------------------------ # # Test connection actions # # ------------------------------------------------------------------ # def action_test_anthropic_connection(self): """Test the Anthropic API key with a minimal call.""" self._test_llm_connection('anthropic', self.blog_pub_anthropic_model or 'claude-haiku-4-5-20251001') def action_test_openai_connection(self): self._test_llm_connection('openai', self.blog_pub_openai_model or 'gpt-4o-mini') def action_test_gemini_connection(self): self._test_llm_connection('gemini', self.blog_pub_gemini_model or 'gemini-2.0-flash') def action_test_ollama_connection(self): self._test_llm_connection('ollama', self.blog_pub_ollama_model or 'mistral') def _test_llm_connection(self, provider: str, model: str): from ..services.llm_router import LLMRouter try: router = LLMRouter(self.env, provider=provider, model=model) # Minimal test prompt — won't produce valid blog JSON but will confirm connectivity raw, tokens = self.env['ir.config_parameter'] # just to keep the pattern consistent # Actually call the provider directly for a small test raw, tokens = self._minimal_provider_test(provider, model) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Connection Successful', 'message': f'{provider.title()} API responded. Tokens used: {tokens}.', 'type': 'success', }, } except Exception as exc: return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Connection Failed', 'message': str(exc), 'type': 'danger', }, } def _minimal_provider_test(self, provider: str, model: str): """Send a tiny prompt to verify API key and connectivity.""" if provider == 'anthropic': from ..services.anthropic_provider import AnthropicProvider key = self.env['ir.config_parameter'].sudo().get_param( 'itsulu_blog_publisher.anthropic_api_key', '' ) p = AnthropicProvider(api_key=key, model=model) return p.generate( system_prompt='You are a test assistant.', user_prompt='Reply with exactly: {"test": "ok"}' ) elif provider == 'openai': from ..services.openai_provider import OpenAIProvider key = self.env['ir.config_parameter'].sudo().get_param( 'itsulu_blog_publisher.openai_api_key', '' ) p = OpenAIProvider(api_key=key, model=model) return p.generate( system_prompt='You are a test assistant.', user_prompt='Reply with exactly: {"test": "ok"}' ) elif provider == 'gemini': from ..services.gemini_provider import GeminiProvider key = self.env['ir.config_parameter'].sudo().get_param( 'itsulu_blog_publisher.gemini_api_key', '' ) p = GeminiProvider(api_key=key, model=model) return p.generate( system_prompt='You are a test assistant.', user_prompt='Reply with exactly: {"test": "ok"}' ) elif provider == 'ollama': from ..services.ollama_provider import OllamaProvider base_url = self.env['ir.config_parameter'].sudo().get_param( 'itsulu_blog_publisher.ollama_base_url', '' ) p = OllamaProvider(base_url=base_url, model=model) return p.generate( system_prompt='You are a test assistant.', user_prompt='Reply with exactly: {"test": "ok"}' ) raise UserError(f"Unknown provider: {provider}")