import json import logging from odoo import models, api, _ from odoo.exceptions import UserError from ._base import LLMProvider _logger = logging.getLogger(__name__) try: import anthropic as anthropic_sdk except ImportError: anthropic_sdk = None class ClaudeAdapter(LLMProvider): """Plain-Python LLMProvider implementation for Anthropic Claude. Preserves all existing functionality (extended thinking, native tool_use blocks) used by the Odoo AbstractModel-based adapter -- this class is additive for the Phase 1 LLMProvider contract. """ supports_tool_calling = True supports_streaming = True max_context_tokens = 200000 supports_embeddings = False def __init__(self, env): super().__init__(env) if anthropic_sdk is None: raise UserError(_("The 'anthropic' Python package is not installed.")) ICP = env['ir.config_parameter'].sudo() try: api_key = env['fusion.api.service'].get_api_key( provider_type='anthropic', consumer='fusion_accounting', feature='chat_with_tools', ) except Exception: api_key = ICP.get_param('fusion_accounting.anthropic_api_key', '') if not api_key: api_key = 'not-needed' self.client = anthropic_sdk.Anthropic(api_key=api_key) self.model = ICP.get_param( 'fusion_accounting.claude_model', 'claude-sonnet-4-6') def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict: api_messages = [ m for m in messages if m.get('role') in ('user', 'assistant') ] try: response = self.client.messages.create( model=self.model, max_tokens=max_tokens, temperature=temperature, system=system, messages=api_messages, ) except Exception as e: _logger.error("Claude complete error: %s", e) raise UserError(_("Claude API error: %s", str(e))) text_parts = [b.text for b in response.content if getattr(b, 'type', None) == 'text'] return { 'content': '\n'.join(text_parts), 'tokens_used': ( getattr(response.usage, 'input_tokens', 0) + getattr(response.usage, 'output_tokens', 0) ), 'model': self.model, } class FusionAccountingAdapterClaude(models.AbstractModel): _name = 'fusion.accounting.adapter.claude' _description = 'Claude AI Adapter' def _get_client(self): if anthropic_sdk is None: raise UserError(_("The 'anthropic' Python package is not installed.")) try: key = self.env['fusion.api.service'].get_api_key( provider_type='anthropic', consumer='fusion_accounting', feature='chat_with_tools', ) except Exception: ICP = self.env['ir.config_parameter'].sudo() key = ICP.get_param('fusion_accounting.anthropic_api_key', '') if not key: raise UserError(_("No Anthropic API key configured.")) return anthropic_sdk.Anthropic(api_key=key) def _get_model_name(self): ICP = self.env['ir.config_parameter'].sudo() return ICP.get_param('fusion_accounting.claude_model', 'claude-sonnet-4-6') def _format_tools(self, tools): formatted = [] for tool in tools: t = { 'name': tool['name'], 'description': tool['description'], 'input_schema': tool.get('parameters', {'type': 'object', 'properties': {}}), } formatted.append(t) return formatted def _supports_extended_thinking(self, model): return '4-6' in model or '4-5' in model or '4-1' in model or '4-0' in model def call_with_tools(self, system_prompt, messages, tools=None, model_override=None): client = self._get_client() model = model_override or self._get_model_name() api_messages = [] for msg in messages: if msg['role'] in ('user', 'assistant'): api_messages.append(msg) kwargs = { 'model': model, 'max_tokens': 16384, 'system': system_prompt, 'messages': api_messages, } if tools: kwargs['tools'] = self._format_tools(tools) if self._supports_extended_thinking(model) and tools: kwargs['thinking'] = { 'type': 'enabled', 'budget_tokens': 8192, } try: response = client.messages.create(**kwargs) except anthropic_sdk.BadRequestError as e: if 'thinking' in str(e).lower(): kwargs.pop('thinking', None) response = client.messages.create(**kwargs) else: raise UserError(_("Claude API error: %s", str(e))) except Exception as e: _logger.error("Claude API error: %s", e) raise UserError(_("Claude API error: %s", str(e))) text_parts = [] tool_calls = [] thinking_text = [] for block in response.content: if block.type == 'text': text_parts.append(block.text) elif block.type == 'tool_use': tool_calls.append({ 'id': block.id, 'name': block.name, 'arguments': block.input, }) elif block.type == 'thinking': thinking_text.append(block.thinking) if thinking_text: _logger.debug("Claude thinking: %s", thinking_text[0][:500]) return { 'text': '\n'.join(text_parts), 'tool_calls': tool_calls if tool_calls else None, 'tokens_in': response.usage.input_tokens, 'tokens_out': response.usage.output_tokens, 'stop_reason': response.stop_reason, 'raw_content': response.content, } def append_tool_results(self, messages, ai_response, tool_results): assistant_content = [] for block in ai_response.get('raw_content', []): if hasattr(block, 'type'): if block.type == 'text': assistant_content.append({'type': 'text', 'text': block.text}) elif block.type == 'tool_use': assistant_content.append({ 'type': 'tool_use', 'id': block.id, 'name': block.name, 'input': block.input, }) messages.append({'role': 'assistant', 'content': assistant_content}) tool_result_content = [] for tr in tool_results: tool_result_content.append({ 'type': 'tool_result', 'tool_use_id': tr['tool_call_id'], 'content': tr['result'], }) messages.append({'role': 'user', 'content': tool_result_content}) return messages