#!/usr/bin/env python3
"""
OpenAI-compatible API for Odoo MCP with AI Provider support.
Supports: Claude, OpenAI, Ollama, or direct command mode.
"""
from __future__ import annotations
import json
import os
import re
import time
import uuid
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from dotenv import load_dotenv, set_key
from pathlib import Path
# Load environment
ENV_FILE = Path(__file__).parent / ".env"
load_dotenv(ENV_FILE)
# Import Odoo functions
import server
app = FastAPI(title="Odoo MCP API", version="1.0.0")
# CORS for Open WebUI
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ============== Configuration ==============
def get_config() -> dict[str, str]:
"""Get current configuration."""
load_dotenv(ENV_FILE, override=True)
return {
# Odoo settings
"ODOO_URL": os.getenv("ODOO_URL", ""),
"ODOO_DB": os.getenv("ODOO_DB", ""),
"ODOO_USERNAME": os.getenv("ODOO_USERNAME", ""),
"ODOO_API_KEY": os.getenv("ODOO_API_KEY", ""),
# AI Provider settings
"AI_PROVIDER": os.getenv("AI_PROVIDER", "none"), # none, openai, claude, gemini, ollama
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
"OPENAI_MODEL": os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
"CLAUDE_MODEL": os.getenv("CLAUDE_MODEL", "claude-sonnet-4-20250514"),
"GEMINI_API_KEY": os.getenv("GEMINI_API_KEY", ""),
"GEMINI_MODEL": os.getenv("GEMINI_MODEL", "gemini-2.0-flash"),
"OLLAMA_URL": os.getenv("OLLAMA_URL", "http://localhost:11434"),
"OLLAMA_MODEL": os.getenv("OLLAMA_MODEL", "llama3.2"),
}
def save_config(config: dict[str, str]) -> None:
"""Save configuration to .env file."""
if not ENV_FILE.exists():
ENV_FILE.touch()
for key, value in config.items():
if value is not None:
set_key(str(ENV_FILE), key, value)
load_dotenv(ENV_FILE, override=True)
# ============== Models ==============
class Message(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
model: str = "odoo-assistant"
messages: list[Message]
stream: bool = False
temperature: float = 0.7
# ============== Tool Registry ==============
TOOLS = {
# Projects & Tasks
"list_projects": {"func": server.list_projects, "desc": "List all projects"},
"list_tasks": {"func": server.list_tasks, "desc": "List tasks"},
# Timesheets
"list_timesheets": {"func": server.list_timesheets, "desc": "List timesheet entries"},
"create_timesheet": {"func": server.create_timesheet, "desc": "Create a timesheet entry"},
"update_timesheet": {"func": server.update_timesheet, "desc": "Update a timesheet entry"},
"delete_timesheet": {"func": server.delete_timesheet, "desc": "Delete a timesheet entry"},
"get_timesheet_summary_by_employee": {"func": server.get_timesheet_summary_by_employee, "desc": "Get timesheet summary by employee"},
# Expenses
"list_expenses": {"func": server.list_expenses, "desc": "List expenses"},
"list_expense_categories": {"func": server.list_expense_categories, "desc": "List expense categories"},
"create_expense": {"func": server.create_expense, "desc": "Create an expense"},
"update_expense": {"func": server.update_expense, "desc": "Update an expense"},
"delete_expense": {"func": server.delete_expense, "desc": "Delete an expense"},
"add_expense_attachment": {"func": server.add_expense_attachment, "desc": "Add attachment to expense"},
"list_expense_attachments": {"func": server.list_expense_attachments, "desc": "List expense attachments"},
# Contacts
"list_contacts": {"func": server.list_contacts, "desc": "List contacts"},
"get_contact": {"func": server.get_contact, "desc": "Get contact details"},
"create_contact": {"func": server.create_contact, "desc": "Create a contact"},
# Invoices
"list_invoices": {"func": server.list_invoices, "desc": "List invoices"},
"get_invoice": {"func": server.get_invoice, "desc": "Get invoice details"},
# Sale Orders
"list_sale_orders": {"func": server.list_sale_orders, "desc": "List sale orders"},
"get_sale_order": {"func": server.get_sale_order, "desc": "Get sale order details"},
# Products
"list_products": {"func": server.list_products, "desc": "List products"},
"get_product": {"func": server.get_product, "desc": "Get product details"},
# Employees
"list_employees": {"func": server.list_employees, "desc": "List employees"},
"get_employee": {"func": server.get_employee, "desc": "Get employee details"},
"list_departments": {"func": server.list_departments, "desc": "List departments"},
# Utilities
"search_records": {"func": server.search_records, "desc": "Search any Odoo model"},
"test_connection": {"func": server.test_connection, "desc": "Test Odoo connection"},
}
# Short aliases for commands
ALIASES = {
# Projects
"projects": "list_projects",
"tasks": "list_tasks",
# Timesheets
"timesheets": "list_timesheets",
"ts": "list_timesheets",
"summary": "get_timesheet_summary_by_employee",
# Expenses
"expenses": "list_expenses",
"exp": "list_expenses",
"categories": "list_expense_categories",
"attachments": "list_expense_attachments",
# Contacts
"contacts": "list_contacts",
# Invoices & Orders
"invoices": "list_invoices",
"orders": "list_sale_orders",
# Products & Employees
"products": "list_products",
"employees": "list_employees",
"departments": "list_departments",
# Utilities
"search": "search_records",
"test": "test_connection",
}
def get_tools_description() -> str:
"""Generate tools description for AI system prompt."""
lines = ["Available Odoo tools:\n"]
for name, tool in TOOLS.items():
# Get function signature
func = tool["func"]
import inspect
sig = inspect.signature(func)
params = []
for pname, param in sig.parameters.items():
if param.default != inspect.Parameter.empty:
params.append(f"{pname}={param.default!r}")
else:
params.append(pname)
params_str = ", ".join(params)
lines.append(f"- {name}({params_str}): {tool['desc']}")
return "\n".join(lines)
def get_system_prompt() -> str:
"""Get the system prompt for AI providers."""
return f"""You are an Odoo assistant. You help users interact with their Odoo ERP system.
{get_tools_description()}
When the user asks for information, determine which tool to call and respond with:
TOOL_CALL: tool_name(arg1=value1, arg2=value2)
For example:
- "Show me the projects" -> TOOL_CALL: list_projects()
- "List employees in department 5" -> TOOL_CALL: list_employees(department_id=5)
- "Find leaves in December" -> TOOL_CALL: search_records(model="hr.leave", field="date_from", value="2025-12")
- "Show timesheets from January" -> TOOL_CALL: list_timesheets(date_from="2025-01-01", date_to="2025-01-31")
If the user just wants to chat or you need clarification, respond normally.
Always be helpful and concise."""
# ============== AI Providers ==============
async def call_openai(messages: list[dict], config: dict) -> str:
"""Call OpenAI API."""
import httpx
headers = {
"Authorization": f"Bearer {config['OPENAI_API_KEY']}",
"Content-Type": "application/json",
}
# Add system prompt
full_messages = [{"role": "system", "content": get_system_prompt()}]
full_messages.extend([{"role": m["role"], "content": m["content"]} for m in messages])
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.openai.com/v1/chat/completions",
headers=headers,
json={
"model": config["OPENAI_MODEL"],
"messages": full_messages,
"temperature": 0.7,
},
timeout=60.0,
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
async def call_claude(messages: list[dict], config: dict) -> str:
"""Call Anthropic Claude API."""
import httpx
headers = {
"x-api-key": config["ANTHROPIC_API_KEY"],
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
}
# Format messages for Claude
claude_messages = [{"role": m["role"], "content": m["content"]} for m in messages]
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.anthropic.com/v1/messages",
headers=headers,
json={
"model": config["CLAUDE_MODEL"],
"max_tokens": 4096,
"system": get_system_prompt(),
"messages": claude_messages,
},
timeout=60.0,
)
response.raise_for_status()
data = response.json()
return data["content"][0]["text"]
async def call_ollama(messages: list[dict], config: dict) -> str:
"""Call Ollama API."""
import httpx
# Add system prompt
full_messages = [{"role": "system", "content": get_system_prompt()}]
full_messages.extend([{"role": m["role"], "content": m["content"]} for m in messages])
async with httpx.AsyncClient() as client:
response = await client.post(
f"{config['OLLAMA_URL']}/api/chat",
json={
"model": config["OLLAMA_MODEL"],
"messages": full_messages,
"stream": False,
},
timeout=120.0,
)
response.raise_for_status()
data = response.json()
return data["message"]["content"]
async def call_gemini(messages: list[dict], config: dict) -> str:
"""Call Google Gemini API."""
import httpx
api_key = config["GEMINI_API_KEY"]
model = config["GEMINI_MODEL"]
# Build contents for Gemini format
contents = []
# Add system instruction separately (Gemini uses systemInstruction)
system_prompt = get_system_prompt()
# Convert messages to Gemini format
for m in messages:
role = "user" if m["role"] == "user" else "model"
contents.append({
"role": role,
"parts": [{"text": m["content"]}]
})
async with httpx.AsyncClient() as client:
response = await client.post(
f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}",
json={
"systemInstruction": {
"parts": [{"text": system_prompt}]
},
"contents": contents,
"generationConfig": {
"temperature": 0.7,
"maxOutputTokens": 4096,
}
},
timeout=60.0,
)
response.raise_for_status()
data = response.json()
return data["candidates"][0]["content"]["parts"][0]["text"]
async def call_ai_provider(messages: list[dict], config: dict) -> str | None:
"""Call the configured AI provider."""
provider = config.get("AI_PROVIDER", "none")
try:
if provider == "openai" and config.get("OPENAI_API_KEY"):
return await call_openai(messages, config)
elif provider == "claude" and config.get("ANTHROPIC_API_KEY"):
return await call_claude(messages, config)
elif provider == "gemini" and config.get("GEMINI_API_KEY"):
return await call_gemini(messages, config)
elif provider == "ollama":
return await call_ollama(messages, config)
except Exception as e:
return f"AI Provider Error: {str(e)}"
return None
# ============== Command Processing ==============
def parse_tool_call(text: str) -> tuple[str | None, dict[str, Any]]:
"""Parse a TOOL_CALL from AI response."""
match = re.search(r'TOOL_CALL:\s*(\w+)\((.*?)\)', text, re.DOTALL)
if not match:
return None, {}
tool_name = match.group(1)
args_str = match.group(2).strip()
args = {}
if args_str:
# Parse key=value pairs
for part in re.findall(r'(\w+)\s*=\s*("[^"]*"|\'[^\']*\'|[^,\)]+)', args_str):
key = part[0]
value = part[1].strip().strip('"\'')
# Type conversion
if value.lower() == "true":
args[key] = True
elif value.lower() == "false":
args[key] = False
elif value.lower() == "none":
args[key] = None
else:
try:
args[key] = int(value)
except ValueError:
try:
args[key] = float(value)
except ValueError:
args[key] = value
return tool_name, args
def parse_direct_command(message: str) -> tuple[str | None, dict[str, Any]]:
"""Parse a direct command from user message."""
message = message.strip().lower()
if message in ("help", "?", "commands", "/help"):
return "help", {}
parts = message.split()
if not parts:
return None, {}
cmd = parts[0].strip("/")
args = {}
# Parse key=value arguments
for part in parts[1:]:
if "=" in part:
key, value = part.split("=", 1)
if value.lower() == "true":
args[key] = True
elif value.lower() == "false":
args[key] = False
else:
try:
args[key] = int(value)
except ValueError:
try:
args[key] = float(value)
except ValueError:
args[key] = value
# Resolve alias
if cmd in ALIASES:
cmd = ALIASES[cmd]
if cmd in TOOLS:
return cmd, args
return None, {}
def execute_tool(tool_name: str, args: dict[str, Any]) -> str:
"""Execute an Odoo tool."""
if tool_name == "help":
return get_help_text()
if tool_name not in TOOLS:
return f"Unknown tool: {tool_name}"
try:
# Refresh Odoo client
server.odoo = server.OdooClient()
result = TOOLS[tool_name]["func"](**args)
return result
except Exception as e:
return f"Error: {str(e)}"
def get_help_text() -> str:
"""Generate help text."""
return """# Odoo Assistant Commands
## Projects & Tasks
- **projects** - List all projects
- **tasks** - List tasks (optional: project_id=X)
## Timesheets
- **timesheets** / **ts** - List timesheets
- **create_timesheet** - Create entry (project_id, hours, description)
- **update_timesheet** - Update entry (timesheet_id, hours, description)
- **delete_timesheet** - Delete entry (timesheet_id)
- **summary** - Timesheet summary by employee (date_from, date_to)
## Expenses
- **expenses** / **exp** - List expenses
- **categories** - List expense categories
- **create_expense** - Create expense (name, product_id, total_amount)
- **update_expense** - Update expense (expense_id, ...)
- **delete_expense** - Delete expense (expense_id)
- **add_expense_attachment** - Add file to expense (expense_id, file_path)
- **attachments** - List expense attachments (expense_id)
## Contacts
- **contacts** - List contacts
- **get_contact** - Get contact details (contact_id)
- **create_contact** - Create contact (name, email, phone, ...)
## Invoices & Orders
- **invoices** - List invoices
- **get_invoice** - Get invoice details (invoice_id)
- **orders** - List sale orders
- **get_sale_order** - Get order details (order_id)
## Products & Employees
- **products** - List products
- **get_product** - Get product details (product_id)
- **employees** - List employees
- **get_employee** - Get employee details (employee_id)
- **departments** - List departments
## Utilities
- **search** - Search any Odoo model (model, field, value)
- **test** - Test Odoo connection
## Examples
- `timesheets date_from=2025-01-01 date_to=2025-01-31`
- `summary date_from=2025-11-01 date_to=2025-11-30`
- `expenses state=draft`
- `employees department_id=5`
- `search model=hr.leave field=date_from value=2025-12`
- `create_timesheet project_id=9 hours=2 description="Development"`
## With AI
If an AI provider is configured, ask in natural language:
- "Show me timesheets from last week"
- "Create a 2-hour timesheet for project Tuyere Camera"
- "What expenses are pending approval?"
- "List employees in the development department"
"""
async def process_message(content: str, messages: list[dict]) -> str:
"""Process a user message."""
config = get_config()
# First try direct command parsing
cmd, args = parse_direct_command(content)
if cmd:
return execute_tool(cmd, args)
# Try AI provider if configured
ai_response = await call_ai_provider(messages, config)
if ai_response:
# Check if AI wants to call a tool
tool_name, tool_args = parse_tool_call(ai_response)
if tool_name:
tool_result = execute_tool(tool_name, tool_args)
return tool_result
# Return AI's direct response
return ai_response
# No AI configured, show help
return (
"# Odoo Assistant\n\n"
"I can help you with Odoo! Try commands like:\n\n"
"**Quick Commands:**\n"
"- `projects` - List projects\n"
"- `timesheets` - List timesheets\n"
"- `expenses` - List expenses\n"
"- `invoices` - List invoices\n"
"- `employees` - List employees\n"
"- `summary date_from=2025-01-01 date_to=2025-01-31` - Hours by employee\n\n"
"**CRUD Operations:**\n"
"- `create_timesheet project_id=9 hours=2 description=\"Dev\"`\n"
"- `create_expense name=\"Taxi\" product_id=5 total_amount=25`\n"
"- `update_expense expense_id=123 total_amount=30`\n\n"
"Type `help` for all commands.\n\n"
"*Configure an AI provider in settings for natural language queries.*"
)
# ============== API Endpoints ==============
@app.get("/", response_class=HTMLResponse)
async def root():
"""Settings page."""
config = get_config()
return f"""
<!DOCTYPE html>
<html>
<head>
<title>Odoo MCP Settings</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #0f172a; color: #f8fafc; min-height: 100vh; padding: 2rem; }}
.container {{ max-width: 800px; margin: 0 auto; }}
h1 {{ margin-bottom: 0.5rem; }}
.subtitle {{ color: #94a3b8; margin-bottom: 2rem; }}
.card {{ background: #1e293b; border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; border: 1px solid #334155; }}
h2 {{ font-size: 1.1rem; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid #334155; }}
.form-group {{ margin-bottom: 1rem; }}
label {{ display: block; margin-bottom: 0.5rem; font-weight: 500; }}
input, select {{ width: 100%; padding: 0.75rem; background: #334155; border: 1px solid #475569; border-radius: 8px; color: #f8fafc; font-size: 1rem; }}
input:focus, select:focus {{ outline: none; border-color: #3b82f6; }}
.hint {{ font-size: 0.85rem; color: #64748b; margin-top: 0.25rem; }}
.btn {{ padding: 0.75rem 1.5rem; border-radius: 8px; border: none; font-size: 1rem; cursor: pointer; font-weight: 500; }}
.btn-primary {{ background: #3b82f6; color: white; }}
.btn-primary:hover {{ background: #2563eb; }}
.btn-secondary {{ background: #334155; color: #f8fafc; margin-right: 0.5rem; }}
.actions {{ display: flex; gap: 1rem; margin-top: 1.5rem; }}
.alert {{ padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }}
.alert-success {{ background: rgba(34, 197, 94, 0.2); border: 1px solid rgba(34, 197, 94, 0.3); }}
.alert-error {{ background: rgba(239, 68, 68, 0.2); border: 1px solid rgba(239, 68, 68, 0.3); }}
.radio-group {{ display: flex; gap: 1rem; flex-wrap: wrap; }}
.radio-option {{ display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1rem; background: #334155; border-radius: 8px; cursor: pointer; }}
.radio-option:hover {{ background: #475569; }}
.radio-option.selected {{ background: #3b82f6; }}
.radio-option input {{ width: auto; }}
.provider-settings {{ display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #334155; }}
.provider-settings.active {{ display: block; }}
</style>
</head>
<body>
<div class="container">
<h1>Odoo MCP Settings</h1>
<p class="subtitle">Configure your Odoo connection and AI provider</p>
<div id="message"></div>
<form id="settingsForm">
<div class="card">
<h2>Odoo Connection</h2>
<div class="form-group">
<label>Odoo URL</label>
<input type="url" name="ODOO_URL" value="{config['ODOO_URL']}" placeholder="https://your-odoo.com">
</div>
<div class="form-group">
<label>Database</label>
<input type="text" name="ODOO_DB" value="{config['ODOO_DB']}" placeholder="my_database">
</div>
<div class="form-group">
<label>Username</label>
<input type="text" name="ODOO_USERNAME" value="{config['ODOO_USERNAME']}" placeholder="admin@example.com">
</div>
<div class="form-group">
<label>API Key</label>
<input type="password" name="ODOO_API_KEY" value="{config['ODOO_API_KEY']}" placeholder="Your Odoo API key">
<p class="hint">Generate in Odoo: Settings > Users > Preferences > API Keys</p>
</div>
</div>
<div class="card">
<h2>AI Provider</h2>
<p class="hint" style="margin-bottom: 1rem;">Select an AI provider for natural language queries</p>
<div class="radio-group">
<label class="radio-option {'selected' if config['AI_PROVIDER'] == 'none' else ''}">
<input type="radio" name="AI_PROVIDER" value="none" {'checked' if config['AI_PROVIDER'] == 'none' else ''} onchange="updateProvider()">
None (Commands only)
</label>
<label class="radio-option {'selected' if config['AI_PROVIDER'] == 'openai' else ''}">
<input type="radio" name="AI_PROVIDER" value="openai" {'checked' if config['AI_PROVIDER'] == 'openai' else ''} onchange="updateProvider()">
OpenAI
</label>
<label class="radio-option {'selected' if config['AI_PROVIDER'] == 'claude' else ''}">
<input type="radio" name="AI_PROVIDER" value="claude" {'checked' if config['AI_PROVIDER'] == 'claude' else ''} onchange="updateProvider()">
Claude (Anthropic)
</label>
<label class="radio-option {'selected' if config['AI_PROVIDER'] == 'gemini' else ''}">
<input type="radio" name="AI_PROVIDER" value="gemini" {'checked' if config['AI_PROVIDER'] == 'gemini' else ''} onchange="updateProvider()">
Gemini (Google)
</label>
<label class="radio-option {'selected' if config['AI_PROVIDER'] == 'ollama' else ''}">
<input type="radio" name="AI_PROVIDER" value="ollama" {'checked' if config['AI_PROVIDER'] == 'ollama' else ''} onchange="updateProvider()">
Ollama (Local)
</label>
</div>
<div id="openai-settings" class="provider-settings {'active' if config['AI_PROVIDER'] == 'openai' else ''}">
<div class="form-group">
<label>OpenAI API Key</label>
<input type="password" name="OPENAI_API_KEY" value="{config['OPENAI_API_KEY']}" placeholder="sk-...">
</div>
<div class="form-group">
<label>Model</label>
<select name="OPENAI_MODEL">
<option value="gpt-4o-mini" {'selected' if config['OPENAI_MODEL'] == 'gpt-4o-mini' else ''}>GPT-4o Mini (Fast, Cheap)</option>
<option value="gpt-4o" {'selected' if config['OPENAI_MODEL'] == 'gpt-4o' else ''}>GPT-4o (Best)</option>
<option value="gpt-4-turbo" {'selected' if config['OPENAI_MODEL'] == 'gpt-4-turbo' else ''}>GPT-4 Turbo</option>
<option value="gpt-3.5-turbo" {'selected' if config['OPENAI_MODEL'] == 'gpt-3.5-turbo' else ''}>GPT-3.5 Turbo</option>
</select>
</div>
</div>
<div id="claude-settings" class="provider-settings {'active' if config['AI_PROVIDER'] == 'claude' else ''}">
<div class="form-group">
<label>Anthropic API Key</label>
<input type="password" name="ANTHROPIC_API_KEY" value="{config['ANTHROPIC_API_KEY']}" placeholder="sk-ant-...">
</div>
<div class="form-group">
<label>Model</label>
<select name="CLAUDE_MODEL">
<option value="claude-sonnet-4-20250514" {'selected' if config['CLAUDE_MODEL'] == 'claude-sonnet-4-20250514' else ''}>Claude Sonnet 4 (Best)</option>
<option value="claude-3-5-sonnet-20241022" {'selected' if config['CLAUDE_MODEL'] == 'claude-3-5-sonnet-20241022' else ''}>Claude 3.5 Sonnet</option>
<option value="claude-3-5-haiku-20241022" {'selected' if config['CLAUDE_MODEL'] == 'claude-3-5-haiku-20241022' else ''}>Claude 3.5 Haiku (Fast)</option>
</select>
</div>
</div>
<div id="gemini-settings" class="provider-settings {'active' if config['AI_PROVIDER'] == 'gemini' else ''}">
<div class="form-group">
<label>Google AI API Key</label>
<input type="password" name="GEMINI_API_KEY" value="{config['GEMINI_API_KEY']}" placeholder="AIza...">
<p class="hint">Get your key at <a href="https://aistudio.google.com/apikey" target="_blank" style="color: #3b82f6;">aistudio.google.com/apikey</a></p>
</div>
<div class="form-group">
<label>Model</label>
<select name="GEMINI_MODEL">
<option value="gemini-2.0-flash" {'selected' if config['GEMINI_MODEL'] == 'gemini-2.0-flash' else ''}>Gemini 2.0 Flash (Fast)</option>
<option value="gemini-1.5-flash" {'selected' if config['GEMINI_MODEL'] == 'gemini-1.5-flash' else ''}>Gemini 1.5 Flash</option>
<option value="gemini-1.5-pro" {'selected' if config['GEMINI_MODEL'] == 'gemini-1.5-pro' else ''}>Gemini 1.5 Pro (Best)</option>
</select>
</div>
</div>
<div id="ollama-settings" class="provider-settings {'active' if config['AI_PROVIDER'] == 'ollama' else ''}">
<div class="form-group">
<label>Ollama URL</label>
<input type="url" name="OLLAMA_URL" value="{config['OLLAMA_URL']}" placeholder="http://localhost:11434">
</div>
<div class="form-group">
<label>Model</label>
<input type="text" name="OLLAMA_MODEL" value="{config['OLLAMA_MODEL']}" placeholder="llama3.2">
<p class="hint">Models: llama3.2, mistral, codellama, etc.</p>
</div>
</div>
</div>
<div class="actions">
<button type="button" class="btn btn-secondary" onclick="testConnection()">Test Odoo</button>
<button type="submit" class="btn btn-primary">Save Settings</button>
</div>
</form>
</div>
<script>
function updateProvider() {{
const provider = document.querySelector('input[name="AI_PROVIDER"]:checked').value;
document.querySelectorAll('.provider-settings').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.radio-option').forEach(el => el.classList.remove('selected'));
if (provider !== 'none') {{
document.getElementById(provider + '-settings').classList.add('active');
}}
document.querySelector('input[name="AI_PROVIDER"]:checked').closest('.radio-option').classList.add('selected');
}}
document.getElementById('settingsForm').onsubmit = async (e) => {{
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
const response = await fetch('/api/settings', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(data)
}});
const result = await response.json();
const msg = document.getElementById('message');
if (result.success) {{
msg.innerHTML = '<div class="alert alert-success">Settings saved successfully!</div>';
}} else {{
msg.innerHTML = '<div class="alert alert-error">Error: ' + result.error + '</div>';
}}
}};
async function testConnection() {{
const msg = document.getElementById('message');
msg.innerHTML = '<div class="alert">Testing connection...</div>';
const response = await fetch('/api/test');
const result = await response.json();
if (result.success) {{
msg.innerHTML = '<div class="alert alert-success">' + result.message + '</div>';
}} else {{
msg.innerHTML = '<div class="alert alert-error">' + result.message + '</div>';
}}
}}
</script>
</body>
</html>
"""
@app.post("/api/settings")
async def save_settings(request: Request):
"""Save settings."""
try:
data = await request.json()
save_config(data)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@app.get("/api/test")
async def test_odoo():
"""Test Odoo connection."""
try:
server.odoo = server.OdooClient()
result = server.test_connection()
return {"success": True, "message": "Connected successfully!"}
except Exception as e:
return {"success": False, "message": str(e)}
@app.get("/v1/models")
@app.get("/api/models")
async def list_models():
"""List available models (OpenAI-compatible)."""
return {
"object": "list",
"data": [{
"id": "odoo-assistant",
"object": "model",
"created": 1700000000,
"owned_by": "odoo-mcp",
"name": "Odoo Assistant",
}]
}
@app.post("/v1/chat/completions")
@app.post("/api/chat/completions")
async def chat_completions(request: ChatRequest):
"""Chat completions endpoint (OpenAI-compatible)."""
user_message = ""
for msg in reversed(request.messages):
if msg.role == "user":
user_message = msg.content
break
if not user_message:
raise HTTPException(status_code=400, detail="No user message found")
# Process message
messages = [{"role": m.role, "content": m.content} for m in request.messages]
response_content = await process_message(user_message, messages)
response_id = f"chatcmpl-{uuid.uuid4().hex[:8]}"
created = int(time.time())
if request.stream:
async def generate():
chunk_size = 50
for i in range(0, len(response_content), chunk_size):
chunk = response_content[i:i + chunk_size]
data = {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": request.model,
"choices": [{"index": 0, "delta": {"content": chunk}, "finish_reason": None}]
}
yield f"data: {json.dumps(data)}\n\n"
yield f"data: {json.dumps({'id': response_id, 'object': 'chat.completion.chunk', 'created': created, 'model': request.model, 'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
return {
"id": response_id,
"object": "chat.completion",
"created": created,
"model": request.model,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": response_content},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": len(user_message.split()),
"completion_tokens": len(response_content.split()),
"total_tokens": len(user_message.split()) + len(response_content.split())
}
}
@app.get("/health")
async def health():
return {"status": "healthy"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)