Provider Adapter Architecture Blueprint
Provider Adapter Architecture Blueprint
Version: 1.0.0
Date: December 2025
Author: CTO Advisory Session
Status: BLUEPRINT (Not Implemented)
Executive Summary
This document defines the Provider Adapter architecture for SAM AI. The goal is to create a clean abstraction layer that allows switching between AI providers (Claude, OpenAI, Google, etc.) without code changes - only configuration.
Key Insight: Only 3 adapters needed to support 100+ providers:
- AnthropicAdapter - Claude (~5% of market)
- OpenAIAdapter - GPT, Groq, Mistral, Ollama, +90 more (~90% of market)
- GoogleAdapter - Gemini (~5% of market)
File Structure
ai_sam_base/
├── adapters/
│ ├── __init__.py # Registry and factory function
│ ├── base_adapter.py # Abstract base class
│ ├── openai_adapter.py # OpenAI-compatible (90% of providers)
│ ├── anthropic_adapter.py # Anthropic/Claude
│ └── google_adapter.py # Google/Gemini (future)
├── models/
│ └── api_service_provider.py # Add get_adapter() method
└── schemas/
└── universal_tool_schema.py # Universal tool definitions
1. Universal Tool Schema
Define tools ONCE in a universal format. Adapters convert automatically.
# schemas/universal_tool_schema.py
"""
Universal Tool Schema - Provider-Agnostic Tool Definitions
Tools are defined in a universal JSON Schema format.
Each adapter converts to provider-specific format automatically.
Universal Format (based on JSON Schema, similar to OpenAI's):
{
"name": "tool_name",
"description": "What the tool does",
"parameters": {
"type": "object",
"properties": {
"param1": {"type": "string", "description": "..."},
"param2": {"type": "integer", "description": "..."}
},
"required": ["param1"]
}
}
Adapters convert to:
- Anthropic: input_schema instead of parameters
- OpenAI: Wraps in {"type": "function", "function": {...}}
- Google: Uses functionDeclarations format
"""
# Universal tool definitions for SAM AI
UNIVERSAL_TOOLS = [
{
"name": "read_file",
"description": "Read the contents of a file from the filesystem. Use this when you need to access actual file contents.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Full absolute path to the file (e.g., C:\\Users\\total\\.claude\\agents\\cmo\\system_prompt.md)"
},
"reason": {
"type": "string",
"description": "Why you need to read this file (shown to user in permission request)"
}
},
"required": ["path", "reason"]
}
},
{
"name": "list_directory",
"description": "List files and folders in a directory. Use this to explore folder contents before reading specific files.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Full absolute path to the directory to list"
},
"pattern": {
"type": "string",
"description": "Optional glob pattern to filter results (e.g., '*.md', '*.json')"
}
},
"required": ["path"]
}
},
{
"name": "glob_files",
"description": "Find files matching a glob pattern. Use this to search for files by name/extension across directories.",
"parameters": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern (e.g., '*.js', '**/*.py', 'test_*.py')"
},
"path": {
"type": "string",
"description": "Starting directory (searches recursively from here)"
}
},
"required": ["pattern", "path"]
}
},
{
"name": "write_file",
"description": "Write content to a file. Creates new file or overwrites existing file.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Full absolute path where to write the file"
},
"content": {
"type": "string",
"description": "Content to write to the file"
},
"reason": {
"type": "string",
"description": "Why you need to write this file (shown to user in permission request)"
}
},
"required": ["path", "content", "reason"]
}
}
]
def get_universal_tools():
"""Return the universal tool definitions."""
return UNIVERSAL_TOOLS.copy()
2. Base Adapter (Abstract Class)
# adapters/base_adapter.py
"""
Base Adapter - Abstract Interface for AI Provider Adapters
All provider adapters must implement this interface.
This ensures consistent behavior across Claude, OpenAI, Google, etc.
"""
from abc import ABC, abstractmethod
from typing import List, Dict, Any, Generator, Optional
import logging
_logger = logging.getLogger(__name__)
class BaseAdapter(ABC):
"""
Abstract base class for AI provider adapters.
Each adapter handles:
1. Tool format conversion (universal -> provider-specific)
2. Message format conversion (internal -> provider-specific)
3. API calls (HTTP requests to provider)
4. Response parsing (provider-specific -> internal)
5. Tool call extraction and result formatting
6. Streaming support
Implementing a new adapter:
1. Inherit from BaseAdapter
2. Implement all abstract methods
3. Register in adapters/__init__.py
"""
# Override in subclass
FORMAT_NAME = "base" # e.g., "openai", "anthropic", "google"
# =========================================================================
# TOOL FORMATTING
# =========================================================================
@abstractmethod
def format_tools(self, universal_tools: List[Dict]) -> List[Dict]:
"""
Convert universal tool definitions to provider-specific format.
Args:
universal_tools: List of tools in universal format
[{
"name": "read_file",
"description": "...",
"parameters": {"type": "object", "properties": {...}}
}]
Returns:
List of tools in provider-specific format
Example (OpenAI):
[{"type": "function", "function": {"name": "...", "parameters": {...}}}]
Example (Anthropic):
[{"name": "...", "description": "...", "input_schema": {...}}]
"""
pass
# =========================================================================
# MESSAGE FORMATTING
# =========================================================================
@abstractmethod
def format_messages(self, messages: List[Dict], system_prompt: Optional[str] = None) -> Dict:
"""
Convert internal message format to provider-specific API payload.
Args:
messages: List of messages in internal format
[{"role": "user", "content": "Hello"}, ...]
system_prompt: Optional system prompt (handled differently per provider)
Returns:
Dict ready for API call (may include system prompt, messages, etc.)
Note: Different providers handle system prompts differently:
- Anthropic: Separate 'system' parameter
- OpenAI: System message in messages array
- Google: System instruction parameter
"""
pass
@abstractmethod
def format_tool_results(self, tool_results: List[Dict], tool_calls: List[Dict]) -> List[Dict]:
"""
Format tool execution results for the next API call.
Args:
tool_results: List of tool execution results
[{"tool_id": "...", "tool_name": "read_file", "result": "file contents..."}]
tool_calls: Original tool calls from AI response
Returns:
Messages to append to conversation for next API call
Note: Format differs significantly:
- Anthropic: user message with tool_result content blocks
- OpenAI: separate messages with role="tool"
"""
pass
# =========================================================================
# API CALLS
# =========================================================================
@abstractmethod
def call_api(
self,
provider: Any, # api.service.provider record
messages: List[Dict],
tools: Optional[List[Dict]] = None,
system_prompt: Optional[str] = None,
max_tokens: int = 4096,
temperature: float = 1.0,
**kwargs
) -> Dict:
"""
Make synchronous API call to provider.
Args:
provider: api.service.provider Odoo record with credentials
messages: Conversation messages (internal format)
tools: Tool definitions (universal format, will be converted)
system_prompt: System prompt
max_tokens: Maximum tokens in response
temperature: Sampling temperature
**kwargs: Provider-specific options
Returns:
Normalized response dict:
{
"content": "Response text",
"tool_calls": [ # Optional, if AI requested tools
{
"id": "call_123",
"name": "read_file",
"input": {"path": "/some/file", "reason": "..."}
}
],
"usage": {
"input_tokens": 100,
"output_tokens": 50
},
"stop_reason": "end_turn" | "tool_use" | "max_tokens",
"model": "claude-3-5-sonnet-20241022"
}
"""
pass
@abstractmethod
def call_api_streaming(
self,
provider: Any,
messages: List[Dict],
tools: Optional[List[Dict]] = None,
system_prompt: Optional[str] = None,
max_tokens: int = 4096,
temperature: float = 1.0,
**kwargs
) -> Generator[Dict, None, None]:
"""
Make streaming API call to provider.
Args:
Same as call_api()
Yields:
Event dicts:
- {"type": "chunk", "data": {"text": "partial response"}}
- {"type": "tool_use", "data": {"id": "...", "name": "...", "input": {...}}}
- {"type": "usage", "data": {"input_tokens": 100, "output_tokens": 50}}
- {"type": "done", "data": {"stop_reason": "end_turn"}}
- {"type": "error", "data": {"message": "error details"}}
"""
pass
# =========================================================================
# RESPONSE PARSING
# =========================================================================
@abstractmethod
def parse_response(self, raw_response: Dict) -> Dict:
"""
Parse raw API response into normalized internal format.
Args:
raw_response: Raw response from provider API
Returns:
Normalized response (same format as call_api return value)
"""
pass
@abstractmethod
def extract_tool_calls(self, response: Dict) -> List[Dict]:
"""
Extract tool calls from API response.
Args:
response: Normalized response dict
Returns:
List of tool calls:
[{"id": "...", "name": "tool_name", "input": {...}}]
"""
pass
# =========================================================================
# UTILITY METHODS (with default implementations)
# =========================================================================
def get_auth_headers(self, provider: Any) -> Dict[str, str]:
"""
Get authentication headers for API request.
Override if provider uses non-standard auth.
Args:
provider: api.service.provider record
Returns:
Headers dict
"""
# Default implementation - override in subclass
return {}
def get_api_endpoint(self, provider: Any) -> str:
"""
Get API endpoint URL.
Args:
provider: api.service.provider record
Returns:
API endpoint URL
"""
return provider.api_endpoint
def should_retry(self, error: Exception, attempt: int) -> bool:
"""
Determine if request should be retried.
Args:
error: The exception that occurred
attempt: Current attempt number (1-based)
Returns:
True if should retry, False otherwise
"""
# Default: retry up to 3 times on connection errors
if attempt >= 3:
return False
error_str = str(error).lower()
retryable = ['timeout', 'connection', '429', '503', '502', 'overloaded']
return any(r in error_str for r in retryable)
def calculate_retry_delay(self, attempt: int) -> float:
"""
Calculate delay before retry (exponential backoff).
Args:
attempt: Current attempt number (1-based)
Returns:
Delay in seconds
"""
import random
base_delay = 1.0
max_delay = 60.0
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
jitter = delay * random.random() * 0.25
return delay + jitter
3. OpenAI Adapter (Covers 90% of Providers)
# adapters/openai_adapter.py
"""
OpenAI Adapter - Handles OpenAI and OpenAI-Compatible APIs
This adapter covers ~90% of AI providers because most have adopted
OpenAI's API format as a de facto standard.
Supported providers (partial list):
- OpenAI (GPT-4, GPT-4o, o1)
- Groq (Llama, Mixtral)
- Together AI
- Mistral AI
- Perplexity
- DeepSeek
- Fireworks AI
- Ollama (local)
- LM Studio (local)
- vLLM (self-hosted)
- Azure OpenAI
- OpenRouter
- +80 more providers
"""
import json
import logging
import requests
from typing import List, Dict, Any, Generator, Optional
from .base_adapter import BaseAdapter
_logger = logging.getLogger(__name__)
class OpenAIAdapter(BaseAdapter):
"""
Adapter for OpenAI and OpenAI-compatible APIs.
API Reference: https://platform.openai.com/docs/api-reference/chat
"""
FORMAT_NAME = "openai"
# Default endpoint (can be overridden per provider)
DEFAULT_ENDPOINT = "https://api.openai.com/v1/chat/completions"
# =========================================================================
# TOOL FORMATTING
# =========================================================================
def format_tools(self, universal_tools: List[Dict]) -> List[Dict]:
"""
Convert universal tools to OpenAI function calling format.
Universal format:
{"name": "...", "description": "...", "parameters": {...}}
OpenAI format:
{"type": "function", "function": {"name": "...", "description": "...", "parameters": {...}}}
"""
if not universal_tools:
return []
openai_tools = []
for tool in universal_tools:
openai_tools.append({
"type": "function",
"function": {
"name": tool["name"],
"description": tool.get("description", ""),
"parameters": tool.get("parameters", {"type": "object", "properties": {}})
}
})
return openai_tools
# =========================================================================
# MESSAGE FORMATTING
# =========================================================================
def format_messages(self, messages: List[Dict], system_prompt: Optional[str] = None) -> Dict:
"""
Format messages for OpenAI API.
OpenAI includes system prompt as first message in array.
"""
formatted_messages = []
# System prompt goes first as a message
if system_prompt:
formatted_messages.append({
"role": "system",
"content": system_prompt
})
# Add conversation messages
for msg in messages:
formatted_messages.append({
"role": msg.get("role", "user"),
"content": msg.get("content", "")
})
return {"messages": formatted_messages}
def format_tool_results(self, tool_results: List[Dict], tool_calls: List[Dict]) -> List[Dict]:
"""
Format tool results for OpenAI.
OpenAI format: Each tool result is a separate message with role="tool"
"""
messages = []
# First, add assistant message with the tool calls
messages.append({
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": json.dumps(tc.get("input", {}))
}
}
for tc in tool_calls
]
})
# Then add tool result messages
for result in tool_results:
messages.append({
"role": "tool",
"tool_call_id": result["tool_id"],
"content": result.get("result", "")
})
return messages
# =========================================================================
# API CALLS
# =========================================================================
def get_auth_headers(self, provider: Any) -> Dict[str, str]:
"""OpenAI uses Bearer token authentication."""
return {
"Authorization": f"Bearer {provider.api_key}",
"Content-Type": "application/json"
}
def call_api(
self,
provider: Any,
messages: List[Dict],
tools: Optional[List[Dict]] = None,
system_prompt: Optional[str] = None,
max_tokens: int = 4096,
temperature: float = 1.0,
**kwargs
) -> Dict:
"""
Make synchronous API call to OpenAI-compatible endpoint.
"""
endpoint = self.get_api_endpoint(provider) or self.DEFAULT_ENDPOINT
headers = self.get_auth_headers(provider)
# Format messages (includes system prompt)
payload = self.format_messages(messages, system_prompt)
# Add model and parameters
payload["model"] = provider.model_name or "gpt-4"
payload["max_tokens"] = max_tokens
payload["temperature"] = temperature
# Add tools if provided
if tools:
payload["tools"] = self.format_tools(tools)
payload["tool_choice"] = "auto"
_logger.info(f"[OpenAIAdapter] Calling {endpoint} with model {payload['model']}")
# Make request with retry logic
attempt = 0
last_error = None
while attempt < 3:
attempt += 1
try:
response = requests.post(
endpoint,
headers=headers,
json=payload,
timeout=120
)
if response.status_code == 200:
return self.parse_response(response.json())
# Handle rate limiting
if response.status_code == 429:
if self.should_retry(Exception("429"), attempt):
import time
delay = self.calculate_retry_delay(attempt)
_logger.warning(f"[OpenAIAdapter] Rate limited, retrying in {delay:.1f}s")
time.sleep(delay)
continue
# Non-retryable error
error_msg = response.text
_logger.error(f"[OpenAIAdapter] API error {response.status_code}: {error_msg}")
raise Exception(f"OpenAI API error {response.status_code}: {error_msg}")
except requests.exceptions.Timeout as e:
last_error = e
if self.should_retry(e, attempt):
import time
delay = self.calculate_retry_delay(attempt)
_logger.warning(f"[OpenAIAdapter] Timeout, retrying in {delay:.1f}s")
time.sleep(delay)
continue
raise
except requests.exceptions.ConnectionError as e:
last_error = e
if self.should_retry(e, attempt):
import time
delay = self.calculate_retry_delay(attempt)
_logger.warning(f"[OpenAIAdapter] Connection error, retrying in {delay:.1f}s")
time.sleep(delay)
continue
raise
raise last_error or Exception("Max retries exceeded")
def call_api_streaming(
self,
provider: Any,
messages: List[Dict],
tools: Optional[List[Dict]] = None,
system_prompt: Optional[str] = None,
max_tokens: int = 4096,
temperature: float = 1.0,
**kwargs
) -> Generator[Dict, None, None]:
"""
Make streaming API call to OpenAI-compatible endpoint.
"""
endpoint = self.get_api_endpoint(provider) or self.DEFAULT_ENDPOINT
headers = self.get_auth_headers(provider)
payload = self.format_messages(messages, system_prompt)
payload["model"] = provider.model_name or "gpt-4"
payload["max_tokens"] = max_tokens
payload["temperature"] = temperature
payload["stream"] = True
if tools:
payload["tools"] = self.format_tools(tools)
payload["tool_choice"] = "auto"
_logger.info(f"[OpenAIAdapter] Streaming from {endpoint}")
try:
response = requests.post(
endpoint,
headers=headers,
json=payload,
stream=True,
timeout=120
)
if response.status_code != 200:
yield {"type": "error", "data": {"message": f"API error {response.status_code}"}}
return
# Track tool calls being assembled (OpenAI streams them in pieces)
pending_tool_calls = {} # index -> {id, name, arguments}
accumulated_text = ""
finish_reason = None
input_tokens = 0
output_tokens = 0
for line in response.iter_lines():
if not line:
continue
line = line.decode('utf-8')
if not line.startswith('data: '):
continue
data = line[6:] # Remove 'data: ' prefix
if data == '[DONE]':
break
try:
chunk = json.loads(data)
# Extract usage if present
if 'usage' in chunk:
input_tokens = chunk['usage'].get('prompt_tokens', 0)
output_tokens = chunk['usage'].get('completion_tokens', 0)
choices = chunk.get('choices', [])
if not choices:
continue
choice = choices[0]
delta = choice.get('delta', {})
finish_reason = choice.get('finish_reason')
# Text content
if 'content' in delta and delta['content']:
accumulated_text += delta['content']
yield {"type": "chunk", "data": {"text": delta['content']}}
# Tool calls (streamed in pieces)
if 'tool_calls' in delta:
for tc in delta['tool_calls']:
idx = tc.get('index', 0)
if idx not in pending_tool_calls:
pending_tool_calls[idx] = {'id': '', 'name': '', 'arguments': ''}
if 'id' in tc:
pending_tool_calls[idx]['id'] = tc['id']
if 'function' in tc:
if 'name' in tc['function']:
pending_tool_calls[idx]['name'] = tc['function']['name']
if 'arguments' in tc['function']:
pending_tool_calls[idx]['arguments'] += tc['function']['arguments']
except json.JSONDecodeError:
continue
# Emit completed tool calls
if pending_tool_calls and finish_reason == 'tool_calls':
for idx, tc_data in pending_tool_calls.items():
try:
input_args = json.loads(tc_data['arguments']) if tc_data['arguments'] else {}
except json.JSONDecodeError:
input_args = {}
yield {
"type": "tool_use",
"data": {
"id": tc_data['id'],
"name": tc_data['name'],
"input": input_args
}
}
# Final usage
yield {
"type": "usage",
"data": {
"input_tokens": input_tokens,
"output_tokens": output_tokens
}
}
# Done
yield {
"type": "done",
"data": {
"stop_reason": finish_reason or "end_turn",
"content": accumulated_text
}
}
except Exception as e:
_logger.error(f"[OpenAIAdapter] Streaming error: {e}")
yield {"type": "error", "data": {"message": str(e)}}
# =========================================================================
# RESPONSE PARSING
# =========================================================================
def parse_response(self, raw_response: Dict) -> Dict:
"""
Parse OpenAI API response to normalized format.
"""
choices = raw_response.get('choices', [])
if not choices:
return {
"content": "",
"tool_calls": [],
"usage": {"input_tokens": 0, "output_tokens": 0},
"stop_reason": "error",
"model": raw_response.get('model', '')
}
choice = choices[0]
message = choice.get('message', {})
# Extract content
content = message.get('content', '') or ''
# Extract tool calls
tool_calls = []
if 'tool_calls' in message:
for tc in message['tool_calls']:
func = tc.get('function', {})
try:
input_args = json.loads(func.get('arguments', '{}'))
except json.JSONDecodeError:
input_args = {}
tool_calls.append({
"id": tc.get('id', ''),
"name": func.get('name', ''),
"input": input_args
})
# Extract usage
usage = raw_response.get('usage', {})
# Map finish reason
finish_reason = choice.get('finish_reason', 'stop')
stop_reason_map = {
'stop': 'end_turn',
'tool_calls': 'tool_use',
'length': 'max_tokens',
'content_filter': 'content_filter'
}
return {
"content": content,
"tool_calls": tool_calls,
"usage": {
"input_tokens": usage.get('prompt_tokens', 0),
"output_tokens": usage.get('completion_tokens', 0)
},
"stop_reason": stop_reason_map.get(finish_reason, finish_reason),
"model": raw_response.get('model', '')
}
def extract_tool_calls(self, response: Dict) -> List[Dict]:
"""Extract tool calls from normalized response."""
return response.get('tool_calls', [])
4. Anthropic Adapter
# adapters/anthropic_adapter.py
"""
Anthropic Adapter - Handles Claude API
Anthropic has a unique API format, distinct from OpenAI.
Key differences:
- System prompt is separate parameter (not in messages)
- Tools use 'input_schema' instead of 'parameters'
- Tool results use content blocks, not separate messages
- Supports prompt caching for cost reduction
"""
import json
import logging
import requests
from typing import List, Dict, Any, Generator, Optional
from .base_adapter import BaseAdapter
_logger = logging.getLogger(__name__)
class AnthropicAdapter(BaseAdapter):
"""
Adapter for Anthropic Claude API.
API Reference: https://docs.anthropic.com/en/api/messages
"""
FORMAT_NAME = "anthropic"
DEFAULT_ENDPOINT = "https://api.anthropic.com/v1/messages"
API_VERSION = "2023-06-01"
# =========================================================================
# TOOL FORMATTING
# =========================================================================
def format_tools(self, universal_tools: List[Dict]) -> List[Dict]:
"""
Convert universal tools to Anthropic format.
Universal format:
{"name": "...", "description": "...", "parameters": {...}}
Anthropic format:
{"name": "...", "description": "...", "input_schema": {...}}
"""
if not universal_tools:
return []
anthropic_tools = []
for tool in universal_tools:
anthropic_tools.append({
"name": tool["name"],
"description": tool.get("description", ""),
"input_schema": tool.get("parameters", {"type": "object", "properties": {}})
})
return anthropic_tools
# =========================================================================
# MESSAGE FORMATTING
# =========================================================================
def format_messages(self, messages: List[Dict], system_prompt: Optional[str] = None) -> Dict:
"""
Format messages for Anthropic API.
Anthropic keeps system prompt separate from messages.
Also supports prompt caching via cache_control.
"""
formatted_messages = []
for msg in messages:
content = msg.get("content", "")
# Handle content that's already a list (e.g., with tool results)
if isinstance(content, list):
formatted_messages.append({
"role": msg.get("role", "user"),
"content": content
})
else:
formatted_messages.append({
"role": msg.get("role", "user"),
"content": content
})
result = {"messages": formatted_messages}
# System prompt is separate in Anthropic
if system_prompt:
# Enable prompt caching for system prompt (90% cost reduction on cache hits)
result["system"] = [
{
"type": "text",
"text": system_prompt,
"cache_control": {"type": "ephemeral"}
}
]
return result
def format_tool_results(self, tool_results: List[Dict], tool_calls: List[Dict]) -> List[Dict]:
"""
Format tool results for Anthropic.
Anthropic format:
1. Assistant message with tool_use content blocks
2. User message with tool_result content blocks
"""
messages = []
# Assistant message with tool_use blocks
assistant_content = []
for tc in tool_calls:
assistant_content.append({
"type": "tool_use",
"id": tc["id"],
"name": tc["name"],
"input": tc.get("input", {})
})
messages.append({
"role": "assistant",
"content": assistant_content
})
# User message with tool_result blocks
user_content = []
for result in tool_results:
user_content.append({
"type": "tool_result",
"tool_use_id": result["tool_id"],
"content": result.get("result", "")
})
messages.append({
"role": "user",
"content": user_content
})
return messages
# =========================================================================
# API CALLS
# =========================================================================
def get_auth_headers(self, provider: Any) -> Dict[str, str]:
"""Anthropic uses x-api-key header."""
return {
"x-api-key": provider.api_key,
"anthropic-version": self.API_VERSION,
"anthropic-beta": "prompt-caching-2024-07-31", # Enable prompt caching
"Content-Type": "application/json"
}
def call_api(
self,
provider: Any,
messages: List[Dict],
tools: Optional[List[Dict]] = None,
system_prompt: Optional[str] = None,
max_tokens: int = 4096,
temperature: float = 1.0,
**kwargs
) -> Dict:
"""
Make synchronous API call to Anthropic.
"""
endpoint = self.get_api_endpoint(provider) or self.DEFAULT_ENDPOINT
headers = self.get_auth_headers(provider)
# Format messages and system prompt
payload = self.format_messages(messages, system_prompt)
# Add model and parameters
payload["model"] = provider.model_name or "claude-3-5-sonnet-20241022"
payload["max_tokens"] = max_tokens
payload["temperature"] = temperature
# Add tools if provided
if tools:
payload["tools"] = self.format_tools(tools)
_logger.info(f"[AnthropicAdapter] Calling {endpoint} with model {payload['model']}")
# Make request with retry logic
attempt = 0
last_error = None
while attempt < 3:
attempt += 1
try:
response = requests.post(
endpoint,
headers=headers,
json=payload,
timeout=120
)
if response.status_code == 200:
return self.parse_response(response.json())
# Handle rate limiting and overloaded
if response.status_code in [429, 529]:
if self.should_retry(Exception(str(response.status_code)), attempt):
import time
delay = self.calculate_retry_delay(attempt)
_logger.warning(f"[AnthropicAdapter] Rate limited/overloaded, retrying in {delay:.1f}s")
time.sleep(delay)
continue
error_msg = response.text
_logger.error(f"[AnthropicAdapter] API error {response.status_code}: {error_msg}")
raise Exception(f"Anthropic API error {response.status_code}: {error_msg}")
except requests.exceptions.Timeout as e:
last_error = e
if self.should_retry(e, attempt):
import time
delay = self.calculate_retry_delay(attempt)
_logger.warning(f"[AnthropicAdapter] Timeout, retrying in {delay:.1f}s")
time.sleep(delay)
continue
raise
except requests.exceptions.ConnectionError as e:
last_error = e
if self.should_retry(e, attempt):
import time
delay = self.calculate_retry_delay(attempt)
_logger.warning(f"[AnthropicAdapter] Connection error, retrying in {delay:.1f}s")
time.sleep(delay)
continue
raise
raise last_error or Exception("Max retries exceeded")
def call_api_streaming(
self,
provider: Any,
messages: List[Dict],
tools: Optional[List[Dict]] = None,
system_prompt: Optional[str] = None,
max_tokens: int = 4096,
temperature: float = 1.0,
**kwargs
) -> Generator[Dict, None, None]:
"""
Make streaming API call to Anthropic.
Uses Anthropic's SSE streaming format.
"""
endpoint = self.get_api_endpoint(provider) or self.DEFAULT_ENDPOINT
headers = self.get_auth_headers(provider)
payload = self.format_messages(messages, system_prompt)
payload["model"] = provider.model_name or "claude-3-5-sonnet-20241022"
payload["max_tokens"] = max_tokens
payload["temperature"] = temperature
payload["stream"] = True
if tools:
payload["tools"] = self.format_tools(tools)
_logger.info(f"[AnthropicAdapter] Streaming from {endpoint}")
try:
response = requests.post(
endpoint,
headers=headers,
json=payload,
stream=True,
timeout=120
)
if response.status_code != 200:
yield {"type": "error", "data": {"message": f"API error {response.status_code}"}}
return
accumulated_text = ""
current_tool_use = None
tool_input_json = ""
input_tokens = 0
output_tokens = 0
stop_reason = None
for line in response.iter_lines():
if not line:
continue
line = line.decode('utf-8')
if not line.startswith('data: '):
continue
data = line[6:]
try:
event = json.loads(data)
event_type = event.get('type', '')
if event_type == 'message_start':
# Extract input tokens from usage
usage = event.get('message', {}).get('usage', {})
input_tokens = usage.get('input_tokens', 0)
elif event_type == 'content_block_start':
block = event.get('content_block', {})
if block.get('type') == 'tool_use':
current_tool_use = {
'id': block.get('id', ''),
'name': block.get('name', ''),
'input': {}
}
tool_input_json = ""
elif event_type == 'content_block_delta':
delta = event.get('delta', {})
delta_type = delta.get('type', '')
if delta_type == 'text_delta':
text = delta.get('text', '')
accumulated_text += text
yield {"type": "chunk", "data": {"text": text}}
elif delta_type == 'input_json_delta':
# Tool input comes in pieces
tool_input_json += delta.get('partial_json', '')
elif event_type == 'content_block_stop':
if current_tool_use:
# Parse accumulated tool input
try:
current_tool_use['input'] = json.loads(tool_input_json) if tool_input_json else {}
except json.JSONDecodeError:
current_tool_use['input'] = {}
yield {"type": "tool_use", "data": current_tool_use}
current_tool_use = None
tool_input_json = ""
elif event_type == 'message_delta':
delta = event.get('delta', {})
stop_reason = delta.get('stop_reason')
usage = event.get('usage', {})
output_tokens = usage.get('output_tokens', 0)
elif event_type == 'message_stop':
pass # End of stream
except json.JSONDecodeError:
continue
# Final usage
yield {
"type": "usage",
"data": {
"input_tokens": input_tokens,
"output_tokens": output_tokens
}
}
# Done
yield {
"type": "done",
"data": {
"stop_reason": stop_reason or "end_turn",
"content": accumulated_text
}
}
except Exception as e:
_logger.error(f"[AnthropicAdapter] Streaming error: {e}")
yield {"type": "error", "data": {"message": str(e)}}
# =========================================================================
# RESPONSE PARSING
# =========================================================================
def parse_response(self, raw_response: Dict) -> Dict:
"""
Parse Anthropic API response to normalized format.
"""
content_blocks = raw_response.get('content', [])
# Extract text content
text_content = ""
tool_calls = []
for block in content_blocks:
if block.get('type') == 'text':
text_content += block.get('text', '')
elif block.get('type') == 'tool_use':
tool_calls.append({
"id": block.get('id', ''),
"name": block.get('name', ''),
"input": block.get('input', {})
})
# Extract usage (includes cache stats)
usage = raw_response.get('usage', {})
return {
"content": text_content,
"tool_calls": tool_calls,
"usage": {
"input_tokens": usage.get('input_tokens', 0),
"output_tokens": usage.get('output_tokens', 0),
"cache_creation_input_tokens": usage.get('cache_creation_input_tokens', 0),
"cache_read_input_tokens": usage.get('cache_read_input_tokens', 0)
},
"stop_reason": raw_response.get('stop_reason', 'end_turn'),
"model": raw_response.get('model', '')
}
def extract_tool_calls(self, response: Dict) -> List[Dict]:
"""Extract tool calls from normalized response."""
return response.get('tool_calls', [])
5. Adapter Registry
# adapters/__init__.py
"""
Provider Adapter Registry
Factory for getting the appropriate adapter based on API format.
"""
from .base_adapter import BaseAdapter
from .openai_adapter import OpenAIAdapter
from .anthropic_adapter import AnthropicAdapter
# from .google_adapter import GoogleAdapter # Future
import logging
_logger = logging.getLogger(__name__)
# Registry of available adapters
ADAPTERS = {
'openai': OpenAIAdapter,
'anthropic': AnthropicAdapter,
# 'google': GoogleAdapter, # Future
}
# Singleton instances (adapters are stateless)
_adapter_instances = {}
def get_adapter(api_format: str) -> BaseAdapter:
"""
Get adapter instance for the given API format.
Args:
api_format: API format string ('openai', 'anthropic', 'google')
Returns:
Adapter instance
Example:
adapter = get_adapter('openai')
response = adapter.call_api(provider, messages, tools)
"""
# Normalize format
api_format = (api_format or 'openai').lower().strip()
# Return cached instance if exists
if api_format in _adapter_instances:
return _adapter_instances[api_format]
# Get adapter class (default to OpenAI for unknown formats)
adapter_class = ADAPTERS.get(api_format, OpenAIAdapter)
if api_format not in ADAPTERS:
_logger.warning(f"[Adapters] Unknown API format '{api_format}', defaulting to OpenAI adapter")
# Create and cache instance
instance = adapter_class()
_adapter_instances[api_format] = instance
_logger.info(f"[Adapters] Created {adapter_class.__name__} for format '{api_format}'")
return instance
def list_adapters() -> list:
"""List all available adapter formats."""
return list(ADAPTERS.keys())
__all__ = [
'BaseAdapter',
'OpenAIAdapter',
'AnthropicAdapter',
'get_adapter',
'list_adapters'
]
6. Provider Model Integration
Add this method to api.service.provider:
# In api_service_provider.py, add to APIServiceProvider class:
def get_adapter(self):
"""
Get the appropriate adapter for this provider's API format.
Returns:
BaseAdapter instance
Example:
provider = env['api.service.provider'].browse(1)
adapter = provider.get_adapter()
response = adapter.call_api(provider, messages, tools)
"""
from ..adapters import get_adapter
return get_adapter(self.api_format or 'openai')
def call_api(self, messages, tools=None, system_prompt=None, **kwargs):
"""
Convenience method to call AI API using this provider's configuration.
Delegates to the appropriate adapter based on api_format.
Args:
messages: List of conversation messages
tools: Optional list of tools (universal format)
system_prompt: Optional system prompt
**kwargs: Additional options (max_tokens, temperature, etc.)
Returns:
Normalized response dict
Example:
provider = env['api.service.provider'].browse(1)
response = provider.call_api(
messages=[{"role": "user", "content": "Hello"}],
tools=UNIVERSAL_TOOLS,
system_prompt="You are a helpful assistant."
)
print(response['content'])
"""
adapter = self.get_adapter()
return adapter.call_api(
provider=self,
messages=messages,
tools=tools,
system_prompt=system_prompt,
**kwargs
)
def call_api_streaming(self, messages, tools=None, system_prompt=None, **kwargs):
"""
Streaming version of call_api.
Yields:
Event dicts (chunk, tool_use, usage, done, error)
"""
adapter = self.get_adapter()
return adapter.call_api_streaming(
provider=self,
messages=messages,
tools=tools,
system_prompt=system_prompt,
**kwargs
)
7. Usage Example
# Example: Using the adapter system
from odoo.addons.ai_sam_base.adapters import get_adapter
from odoo.addons.ai_sam_base.schemas.universal_tool_schema import UNIVERSAL_TOOLS
# Get a provider (could be Claude, GPT, Groq, etc.)
provider = env['api.service.provider'].search([
('vendor_key', '=', 'anthropic'),
('is_template', '=', False)
], limit=1)
# Option 1: Use provider's convenience method
response = provider.call_api(
messages=[{"role": "user", "content": "Read the file /tmp/test.txt"}],
tools=UNIVERSAL_TOOLS,
system_prompt="You are SAM AI, a helpful assistant."
)
if response['tool_calls']:
# Handle tool calls
for tc in response['tool_calls']:
print(f"AI wants to call: {tc['name']} with {tc['input']}")
# Option 2: Use adapter directly (more control)
adapter = get_adapter(provider.api_format)
response = adapter.call_api(
provider=provider,
messages=messages,
tools=UNIVERSAL_TOOLS,
max_tokens=8192,
temperature=0.7
)
# Streaming example
for event in provider.call_api_streaming(messages, tools=UNIVERSAL_TOOLS):
if event['type'] == 'chunk':
print(event['data']['text'], end='', flush=True)
elif event['type'] == 'tool_use':
print(f"\n[Tool call: {event['data']['name']}]")
elif event['type'] == 'done':
print(f"\n[Done: {event['data']['stop_reason']}]")
8. Migration Path
Step 1: Create adapter files (Day 1)
- Create
adapters/directory - Add
__init__.py,base_adapter.py,openai_adapter.py,anthropic_adapter.py
Step 2: Add provider methods (Day 1)
- Add
get_adapter(),call_api(),call_api_streaming()toapi.service.provider
Step 3: Update ai_brain.py (Day 2-3)
- Replace
_call_openai_api()calls withprovider.call_api() - Replace
_call_claude_api()calls withprovider.call_api() - Remove inline bifurcation code
Step 4: Update tool definitions (Day 3)
- Convert
SAM_TOOLS_V1to universal format - Update references in
sam_voice.py
Step 5: Test (Day 4)
- Test with OpenAI provider
- Test with Anthropic provider
- Test tool calling with both
- Test streaming with both
9. Benefits Summary
| Before | After |
|---|---|
| 182 hardcoded provider references | 3 adapter classes |
Inline if api_format == 'anthropic' everywhere |
Single dispatch via get_adapter() |
| Tool definitions in Anthropic format only | Universal format, auto-converted |
| Adding new provider = touch 10+ files | Adding new provider = 1 adapter file |
| Testing requires mocking both formats | Each adapter testable in isolation |
10. Future Extensions
Google Adapter (When Needed)
class GoogleAdapter(BaseAdapter):
FORMAT_NAME = "google"
# Implement for Gemini API
AWS Bedrock Adapter (When Needed)
class BedrockAdapter(BaseAdapter):
FORMAT_NAME = "bedrock"
# Implement for AWS Bedrock (Claude, Titan, etc.)
MCP Server (Phase 2)
Once adapters are in place, building an MCP server becomes trivial because:
- Tool definitions already in universal format
- Response format already normalized
- MCP just becomes another "adapter" on the client side