Phase 1 prerequisite for local LLM support. Adapters now declare capability flags (supports_tool_calling, max_context_tokens, etc.) so the engine can reason about what backend is available. OpenAI adapter accepts fusion_accounting.openai_base_url config -- point it at LM Studio (http://host.docker.internal:1234/v1) or Ollama (http://host.docker.internal:11434/v1) and the existing OpenAI adapter works unchanged. Implementation note: existing Odoo AbstractModel adapters (fusion.accounting.adapter.openai/claude) are preserved untouched to avoid breaking the chat panel; the new plain-Python OpenAIAdapter and ClaudeAdapter classes (LLMProvider subclasses) are added alongside them. Made-with: Cursor
202 lines
7.0 KiB
Python
202 lines
7.0 KiB
Python
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
|