Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
439 changes: 110 additions & 329 deletions README.md

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions llm/models/llm_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,17 +383,20 @@ def format_tools(self, tools):
"""Format tools for the specific provider"""
return self._dispatch("format_tools", tools)

def format_messages(self, messages, system_prompt=None):
def format_messages(self, messages, system_prompt=None, **kwargs):
"""Format messages for this provider

Args:
messages: List of messages to format for specific provider, could be mail.message record set or similar data format
messages: Messages to format, such as a mail.message recordset
system_prompt: Optional system prompt to include at the beginning of the messages
**kwargs: Additional provider-specific formatting parameters

Returns:
List of formatted messages in provider-specific format
"""
return self._dispatch("format_messages", messages, system_prompt=system_prompt)
return self._dispatch(
"format_messages", messages, system_prompt=system_prompt, **kwargs
)

def _get_provider_tool_params(self, tools, kwargs):
"""Hook for provider-specific tool parameters."""
Expand Down
180 changes: 178 additions & 2 deletions llm/models/mail_message.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,84 @@
import base64
import logging

from odoo import api, fields, models, tools

_logger = logging.getLogger(__name__)

IMAGE_MIMETYPES = (
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
)

IMAGE_MAGIC_BYTES = {
b"\x89PNG\r\n\x1a\n": "image/png",
b"\xff\xd8\xff": "image/jpeg",
b"GIF87a": "image/gif",
b"GIF89a": "image/gif",
b"RIFF": "image/webp",
}

PDF_MIMETYPES = ("application/pdf",)

AUDIO_MIMETYPES = (
"audio/wav",
"audio/x-wav",
"audio/mpeg",
"audio/mp3",
"audio/ogg",
"audio/flac",
"audio/webm",
"audio/mp4",
"audio/m4a",
"audio/x-m4a",
)

TEXT_MIMETYPES = (
"text/plain",
"text/markdown",
"text/csv",
"text/html",
"text/css",
"text/javascript",
"text/xml",
"text/x-python",
"application/json",
"application/xml",
"application/javascript",
"application/x-python-code",
)

SUPPORTED_IMAGE_MIMETYPES = IMAGE_MIMETYPES


def _detect_image_mimetype(raw_bytes):
"""Detect the real image mimetype from magic bytes."""
for magic, mimetype in IMAGE_MAGIC_BYTES.items():
if raw_bytes.startswith(magic):
if magic == b"RIFF" and len(raw_bytes) >= 12:
if raw_bytes[8:12] != b"WEBP":
continue
return mimetype
return None


def _detect_audio_format(raw_bytes):
"""Detect audio format from magic bytes for OpenAI API."""
if raw_bytes[:4] == b"RIFF" and len(raw_bytes) >= 12:
if raw_bytes[8:12] == b"WAVE":
return "wav"
if raw_bytes[:3] == b"ID3" or raw_bytes[:2] == b"\xff\xfb":
return "mp3"
if raw_bytes[:4] == b"fLaC":
return "flac"
if raw_bytes[:4] == b"OggS":
return "ogg"
if raw_bytes[4:8] == b"ftyp":
return "mp4"
return None


class MailMessage(models.Model):
"""Extension of mail.message to handle LLM-specific message subtypes."""
Expand Down Expand Up @@ -67,7 +146,7 @@ def get_llm_role(self):
DEPRECATED: Use the llm_role computed field instead.

Returns:
str or False: The role name ('user', 'assistant', 'tool', 'system') or False if not an LLM message
str or False: The role name, or False if not an LLM message.
"""
self.ensure_one()
return self.llm_role
Expand Down Expand Up @@ -101,7 +180,7 @@ def _check_llm_role(self, role):
return {message: message.llm_role == role for message in self}

def to_store_format(self):
"""Convert message to store format compatible with Odoo 18.0. Used by frontend js components"""
"""Convert message to the store format used by frontend components."""
self.ensure_one()
from odoo.addons.mail.tools.discuss import Store

Expand All @@ -110,3 +189,100 @@ def to_store_format(self):
result = store.get_result()

return result["mail.message"][0]

def _get_attachments_by_mimetype(self, mimetypes):
"""Get attachments filtered by mimetype."""
self.ensure_one()
return self.attachment_ids.filtered(
lambda att: att.mimetype and att.mimetype in mimetypes and att.datas,
)

def _get_image_attachments(self):
"""Get image attachments with validated mimetype from magic bytes."""
images = []
for att in self._get_attachments_by_mimetype(SUPPORTED_IMAGE_MIMETYPES):
try:
raw_bytes = base64.b64decode(att.datas)
real_mimetype = _detect_image_mimetype(raw_bytes)

if real_mimetype:
if real_mimetype != att.mimetype:
_logger.debug(
"Image %s: correcting mimetype from %s to %s",
att.name,
att.mimetype,
real_mimetype,
)
images.append(
{
"mimetype": real_mimetype,
"data": att.datas.decode("utf-8"),
"name": att.name or "image",
},
)
else:
_logger.warning(
"Could not detect image type for %s, using stored mimetype %s",
att.name,
att.mimetype,
)
images.append(
{
"mimetype": att.mimetype,
"data": att.datas.decode("utf-8"),
"name": att.name or "image",
},
)
except (ValueError, TypeError) as e:
_logger.warning("Failed to process image attachment %s: %s", att.name, e)
return images

def _get_pdf_attachments(self):
"""Get PDF attachments as base64 data."""
return [
{
"mimetype": att.mimetype,
"data": att.datas.decode("utf-8"),
"name": att.name or "document.pdf",
}
for att in self._get_attachments_by_mimetype(PDF_MIMETYPES)
]

def _get_text_attachments(self):
"""Get text attachments with decoded content."""
texts = []
for att in self._get_attachments_by_mimetype(TEXT_MIMETYPES):
try:
raw_data = base64.b64decode(att.datas)
content = raw_data.decode("utf-8")
texts.append(
{
"mimetype": att.mimetype,
"content": content,
"name": att.name or "file.txt",
},
)
except (UnicodeDecodeError, ValueError) as e:
_logger.warning("Failed to decode text attachment %s: %s", att.name, e)
return texts

def _get_audio_attachments(self):
"""Get audio attachments with detected format for OpenAI API."""
audios = []
for att in self._get_attachments_by_mimetype(AUDIO_MIMETYPES):
try:
raw_bytes = base64.b64decode(att.datas)
audio_format = _detect_audio_format(raw_bytes)
if audio_format:
audios.append(
{
"format": audio_format,
"data": att.datas.decode("utf-8"),
"name": att.name or "audio",
},
)
else:
_logger.warning("Could not detect audio format for %s", att.name)
except (ValueError, TypeError) as e:
_logger.warning("Failed to process audio attachment %s: %s", att.name, e)
return audios
169 changes: 169 additions & 0 deletions llm_anthropic/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Anthropic Provider for Odoo LLM Integration

This module integrates Anthropic's Claude API with the Odoo LLM framework, providing access to Claude models for chat, tool calling, and extended thinking capabilities.

**Module Type:** 🔧 Provider

## Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│ Used By (Any LLM Module) │
│ ┌─────────────┐ ┌───────────┐ ┌─────────────┐ ┌───────────┐ │
│ │llm_assistant│ │llm_thread │ │llm_knowledge│ │llm_generate│ │
│ └──────┬──────┘ └─────┬─────┘ └──────┬──────┘ └─────┬─────┘ │
└─────────┼───────────────┼───────────────┼───────────────┼───────┘
│ │ │ │
└───────────────┴───────┬───────┴───────────────┘
┌───────────────────────────────────────────────┐
│ ★ llm_anthropic (This Module) ★ │
│ Anthropic Provider │
│ Claude 4.5 │ Claude 4 │ Claude 3.x │ Vision │
└─────────────────────┬─────────────────────────┘
┌───────────────────────────────────────────────┐
│ llm │
│ (Core Base Module) │
└───────────────────────────────────────────────┘
```

## Installation

### What to Install

**For AI chat with Claude:**

```bash
odoo-bin -d your_db -i llm_assistant,llm_anthropic
```

### Auto-Installed Dependencies

- `llm` (core infrastructure)
- `llm_tool` (tool/function calling support)

### Alternative Providers

| Instead of Anthropic | Use | Best For |
| -------------------- | ---------- | --------------------- |
| `llm_openai` | OpenAI | GPT models, DALL-E |
| `llm_ollama` | Local AI | Privacy, no API costs |
| `llm_mistral` | Mistral AI | European, fast |

### Common Setups

| I want to... | Install |
| ------------------------ | ---------------------------------------- |
| Chat with Claude | `llm_assistant` + `llm_anthropic` |
| Claude + document search | Above + `llm_knowledge` + `llm_pgvector` |
| Claude + external tools | Above + `llm_mcp_server` |

## Features

- Connect to Anthropic API with proper authentication
- Support for all Claude models (4.5, 4, 3.x series)
- Tool/function calling capabilities
- Extended thinking support (Claude's reasoning mode)
- Streaming responses
- Multimodal (vision) capabilities for supported models
- Automatic model discovery

## Multimodal Support

Claude models support sending images and PDFs along with text messages.

### How to Use

1. Attach files (images, PDFs, text files) to your chat message
2. The module automatically:
- Converts images to base64 for Claude's vision API
- Sends PDFs as document blocks
- Includes text file contents in the message

### Supported Formats

| Type | Formats | Claude Format |
| ------ | --------------------------------- | ------------------------------------- |
| Images | JPEG, PNG, GIF, WebP | `type: "image"` with base64 source |
| PDFs | application/pdf | `type: "document"` with base64 source |
| Text | .txt, .md, .csv, .py, .json, etc. | Appended to message text |

### Example

```python
# Attach an image to the chat thread
thread.message_post(
body="What's in this image?",
attachment_ids=[(4, image_attachment.id)]
)
# Claude will analyze the image and respond
```

**Note:** Non-multimodal models (like older Claude versions) will skip images/PDFs automatically.

## Configuration

1. Install the module
2. Navigate to **LLM > Configuration > Providers**
3. Create a new provider and select "Anthropic" as the provider type
4. Enter your Anthropic API key
5. Click "Fetch Models" to import available models

## Supported Models

| Model Family | Models | Capabilities |
| ------------ | ------------------- | -------------------------------------- |
| Claude 4.5 | Opus, Sonnet, Haiku | Chat, Vision, Tools, Extended Thinking |
| Claude 4 | Opus, Sonnet | Chat, Vision, Tools |
| Claude 3.x | Opus, Sonnet, Haiku | Chat, Vision, Tools |

## Technical Details

This module extends the base LLM integration framework with Anthropic-specific implementations:

### Key Differences from OpenAI

| Aspect | OpenAI | Anthropic |
| ---------------- | ----------------------------------------- | ----------------------------------------- |
| System message | In messages array | Separate `system` parameter |
| Tool format | `{"type": "function", "function": {...}}` | `{"name", "description", "input_schema"}` |
| Response content | Single string | Array of content blocks |
| Tool results | `role: "tool"` | `role: "user"` + `type: "tool_result"` |

### Extended Thinking

Claude supports extended thinking mode, which allows the model to show its reasoning process:

```python
# Enable extended thinking in your assistant configuration
response = provider.chat(
messages=messages,
extended_thinking=True,
thinking_budget=10000 # tokens for reasoning
)
```

### Implemented Methods

- `anthropic_get_client()` - Initialize Anthropic client
- `anthropic_chat()` - Chat with tool calling and streaming support
- `anthropic_format_tools()` - Convert tools to Anthropic format
- `anthropic_format_messages()` - Format mail.message records
- `anthropic_models()` - List available Claude models
- `anthropic_normalize_prepend_messages()` - Handle prepend messages

## Dependencies

- `llm` (LLM Integration Base)
- `llm_tool` (Tool Calling Support)
- Python: `anthropic` package

## Contributors

- Crottolo <[email protected]> - Odoo 18.0 port with full tool calling and extended thinking support

## License

LGPL-3
1 change: 1 addition & 0 deletions llm_anthropic/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
Loading