Files
enterprise-addons/ai/utils/ai_logging.py
T

172 lines
5.8 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import threading
import time
from contextlib import contextmanager
_logger = logging.getLogger(__name__)
_logging_sessions = threading.local()
def estimate_tokens(content) -> int:
"""Estimate token count using OpenAI's heuristic of 1 token ~= 4 characters.
:param content: Content to estimate tokens for (string, dict, list, or any object)
:return: Estimated number of tokens
"""
if isinstance(content, str):
text = content
elif isinstance(content, (dict, list)):
text = json.dumps(content, separators=(",", ":"))
else:
text = str(content)
return len(text) // 4
def get_ai_logging_session():
"""Get the current AI logging session if one exists.
:return: Current session dict or None
"""
try:
return _logging_sessions.ai_logging_session
except AttributeError:
return None
@contextmanager
def ai_response_logging(llm_model: str):
"""Context manager for logging AI responses.
Tracks API calls, tool executions, token usage, and timing information
for a single AI response generation session.
:param llm_model: Name of the LLM model being used
"""
session = {
"llm_model": llm_model,
"start_time": time.perf_counter(),
"api_calls": 0,
"tool_calls": 0,
"tokens_in": 0,
"real_input_tokens": 0,
"output_tokens": 0,
"cached_tokens": 0,
"api_time": 0.0,
"tool_time": 0.0,
"batch_count": 0,
"current_batch_id": None,
}
_logger.debug("[AI Response] Starting generation for model '%s'", llm_model)
_logging_sessions.ai_logging_session = session
try:
yield
finally:
if session["api_calls"]:
duration = time.perf_counter() - session["start_time"]
_logger.info(
"[AI Summary] Total: %.2fs | API calls: %d (%.2fs) | Tools: %d (%.2fs) | "
"Tokens: %d (in: %d, out: %d, cached: %d) | Batches: %d",
duration,
session["api_calls"],
session["api_time"],
session["tool_calls"],
session["tool_time"],
session["real_input_tokens"] + session["output_tokens"],
session["real_input_tokens"],
session["output_tokens"],
session["cached_tokens"],
session["batch_count"],
)
_logging_sessions.ai_logging_session = None
@contextmanager
def api_call_logging(messages, tools=None):
"""Context manager for logging API calls with automatic timing and response tracking.
Tracks API request/response, estimates token usage, and handles timing even if
exceptions occur during the API call.
:param messages: Messages being sent to the API
:param tools: Optional tools/functions available to the API
:yields: Function to record response data, or None if no session
"""
session = get_ai_logging_session()
if not session:
yield None
return
session["api_calls"] += 1
call_id = session["api_calls"]
tokens_in = estimate_tokens(messages)
if tools:
tool_data = {}
for name, (desc, __, __, schema) in tools.items():
tool_data[name] = {"description": desc, "schema": schema}
tokens_in += estimate_tokens(tool_data)
session["tokens_in"] += tokens_in
_logger.debug("[AI API Call #%d] Sending request with estimated %d tokens", call_id, tokens_in)
start_time = time.perf_counter()
response_data = {"tool_calls": [], "real_input_tokens": 0, "output_tokens": 0, "cached_tokens": 0}
def record_response(tool_calls, response, request_token_usage):
"""Record the API response data.
:param tool_calls: List of tool calls in the response
:param response: The API response
"""
response_data["tool_calls"] = tool_calls or []
if request_token_usage:
response_data["real_input_tokens"] = request_token_usage.get("input_tokens")
response_data["output_tokens"] = request_token_usage.get("output_tokens")
response_data["cached_tokens"] = request_token_usage.get("cached_tokens")
try:
yield record_response
finally:
duration = time.perf_counter() - start_time
session["api_time"] += duration
session["real_input_tokens"] += response_data["real_input_tokens"]
session["output_tokens"] += response_data["output_tokens"]
session["cached_tokens"] = response_data["cached_tokens"] # should already by cumulative
if response_data["tool_calls"]:
num_tools = len(response_data["tool_calls"])
is_batch = num_tools > 1
if is_batch:
session["batch_count"] += 1
session["current_batch_id"] = session["batch_count"]
_logger.debug(
"[AI API Call #%d - ⚡] Received Batch #%d, %d tool calls (%.2fs, %d tokens)",
call_id,
session["current_batch_id"],
num_tools,
duration,
response_data["output_tokens"],
)
else:
session["current_batch_id"] = None
_logger.debug(
"[AI API Call #%d - →] Received single tool call (%.2fs, %d tokens)",
call_id,
duration,
response_data["output_tokens"],
)
else:
session["current_batch_id"] = None
_logger.debug(
"[AI API Call #%d] Completed (%.2fs, %d tokens)",
call_id,
duration,
response_data["output_tokens"],
)