172 lines
5.8 KiB
Python
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"],
|
|
)
|