744 lines
29 KiB
Python
744 lines
29 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import copy
|
|
import json
|
|
import os
|
|
import requests
|
|
import typing
|
|
from logging import getLogger
|
|
import time
|
|
from typing import Callable, Any
|
|
|
|
from odoo import _
|
|
from odoo.api import Environment
|
|
from odoo.exceptions import UserError
|
|
|
|
from .ai_logging import ai_response_logging, api_call_logging, get_ai_logging_session
|
|
from .llm_providers import check_model_depreciation
|
|
|
|
_logger = getLogger(__name__)
|
|
|
|
|
|
class Embedding(typing.TypedDict):
|
|
index: int
|
|
embedding: list[float]
|
|
object: str
|
|
|
|
|
|
class EmbeddingResponse(typing.TypedDict):
|
|
object: str
|
|
data: list[Embedding]
|
|
model: str
|
|
usage: dict
|
|
|
|
|
|
class RealtimeSessionAudioInputFormatParameter(typing.TypedDict, total=False):
|
|
type: str | None
|
|
rate: int | None
|
|
|
|
|
|
class RealtimeSessionAudioInputTranscriptionParameter(typing.TypedDict, total=False):
|
|
language: str | None
|
|
model: str | None
|
|
prompt: str | None
|
|
|
|
|
|
class RealtimeSessionAudioInputTurnDetectionParameter(typing.TypedDict, total=False):
|
|
create_response: bool | None
|
|
eagerness: str | None
|
|
idle_timeout_ms: int | None
|
|
interrupt_response: bool | None
|
|
prefix_padding_ms: int | None
|
|
silence_duration_ms: int | None
|
|
threshold: float | None
|
|
type: str | None
|
|
|
|
|
|
class RealtimeSessionAudioInputNoiseReductionParameter(typing.TypedDict, total=False):
|
|
type: str | None
|
|
|
|
|
|
class RealtimeSessionAudioInputParameter(typing.TypedDict, total=False):
|
|
format: RealtimeSessionAudioInputFormatParameter | None
|
|
noise_reduction: RealtimeSessionAudioInputNoiseReductionParameter | None
|
|
transcription: RealtimeSessionAudioInputTranscriptionParameter | None
|
|
turn_detection: RealtimeSessionAudioInputTurnDetectionParameter | None
|
|
|
|
|
|
class RealtimeSessionAudioParameter(typing.TypedDict, total=False):
|
|
input: RealtimeSessionAudioInputParameter | None
|
|
|
|
|
|
class RealtimeSessionParameter(typing.TypedDict, total=False):
|
|
type: str
|
|
audio: RealtimeSessionAudioParameter | None
|
|
include: list[str] | None
|
|
|
|
|
|
class RealtimeExpiresAfterParameter(typing.TypedDict, total=False):
|
|
anchor: str | None
|
|
seconds: int | None
|
|
|
|
|
|
class RealtimeParameters(typing.TypedDict, total=False):
|
|
expires_after: RealtimeExpiresAfterParameter | None
|
|
session: RealtimeSessionParameter | None
|
|
|
|
|
|
class LLMApiService:
|
|
def __init__(self, env: Environment, provider: str = 'openai') -> None:
|
|
self.provider = provider
|
|
base_url = None
|
|
if self.provider == 'openai':
|
|
base_url = "https://api.openai.com/v1"
|
|
elif self.provider == 'google':
|
|
base_url = "https://generativelanguage.googleapis.com/v1beta/openai"
|
|
else:
|
|
raise NotImplementedError(f"Unsupported provider: {self.provider}")
|
|
|
|
self.base_url = base_url
|
|
self.env = env
|
|
|
|
def get_embedding(
|
|
self,
|
|
input: str | list[str] | list[int] | list[list[int]],
|
|
dimensions: int,
|
|
model: str = 'text-embedding-3-small',
|
|
encoding_format: str | None = None,
|
|
user: str | None = None,
|
|
) -> EmbeddingResponse:
|
|
check_model_depreciation(self.env, model)
|
|
body = {
|
|
'input': input,
|
|
'model': model
|
|
}
|
|
self._add_if_set(body, 'encoding_format', encoding_format)
|
|
self._add_if_set(body, 'dimensions', dimensions)
|
|
self._add_if_set(body, 'user', user)
|
|
|
|
return self._request(
|
|
method='post',
|
|
endpoint='/embeddings',
|
|
headers=self._get_base_headers(),
|
|
body=body,
|
|
)
|
|
|
|
def _format_for_embedding(self, content: str, mode: str, title: str | None = None) -> str:
|
|
if self.provider != 'google':
|
|
return content
|
|
|
|
# https://ai.google.dev/gemini-api/docs/embeddings#task-types-embeddings-2
|
|
if mode == 'query':
|
|
return f'task: question answering | query: {content}'
|
|
|
|
if mode == 'document':
|
|
return f'title: {title} | text: {content}'
|
|
|
|
def get_transcription(
|
|
self,
|
|
data: bytes,
|
|
mimetype: str = "audio/ogg",
|
|
model: str = "whisper-1",
|
|
language: str | None = None,
|
|
prompt: str | None = None,
|
|
response_format: str = "verbose_json",
|
|
temperature: float | None = None
|
|
):
|
|
""" Submit audio data for transcription and return the transcribed text
|
|
Logs real-time factor: `o_rtf = (transmission + inference_time) / audio_duration`
|
|
:param data: The audio file as raw bytes (not a filename or path!).
|
|
:param mimetype: MIME type of the audio data, defaults to "audio/ogg".
|
|
:param model: model to use for the transcription, defaults to 'whisper-1'
|
|
:param language: language of the audio
|
|
:param prompt: Optional text used to guide the model's style or continue a previous audio segment. Only supports english.
|
|
:param response_format: format of the output of the model. Types: 'json', 'text', 'srt', 'verbose_json', or 'vtt'
|
|
:param temperature: randomness level of the model. Ranges from 0 to 1.
|
|
:return: str | None: The transcribed text if successful, or None if the transcription failed.
|
|
|
|
Example:
|
|
```python
|
|
from odoo.addons.ai.utils.llm_api_service import LLMApiService
|
|
service = LLMApiService(self.env)
|
|
with open("audio.ogg", "rb") as f:
|
|
audio_bytes = f.read()
|
|
text = service.get_transcription(audio_bytes, mimetype="audio/ogg")
|
|
```
|
|
"""
|
|
check_model_depreciation(self.env, model)
|
|
if response_format not in ['json', 'verbose_json']: # limitation of using response.json() in _request function
|
|
raise NotImplementedError(f"Response format '{response_format}' is not supported. Request must return json!")
|
|
|
|
headers = {
|
|
'Authorization': f'Bearer {self._get_api_token()}',
|
|
}
|
|
body = {
|
|
"model": model,
|
|
"response_format": response_format
|
|
}
|
|
self._add_if_set(body, "prompt", prompt)
|
|
self._add_if_set(body, "language", language)
|
|
self._add_if_set(body, "temperature", temperature)
|
|
|
|
start = time.perf_counter()
|
|
response = self._request(
|
|
method="post",
|
|
endpoint="/audio/transcriptions",
|
|
headers=headers,
|
|
body={},
|
|
data=body,
|
|
files={"file": ("audio", data, mimetype)},
|
|
timeout=550
|
|
)
|
|
elapsed = time.perf_counter() - start
|
|
|
|
if not response or 'text' not in response:
|
|
_logger.warning("No transcription received.")
|
|
return None
|
|
|
|
# Observed RTF (request time + transcription time)
|
|
o_rft_text = ""
|
|
audio_duration = response.get('duration', False)
|
|
if audio_duration and audio_duration > 0:
|
|
o_rtf = elapsed / audio_duration
|
|
o_rft_text = f"(Observed RTF: {o_rtf:.2f} for {audio_duration:.1f}s audio)"
|
|
_logger.info("Transcription job done in %.1fs %s", elapsed, o_rft_text)
|
|
return response.get('text')
|
|
|
|
def get_transcription_session(self, config: RealtimeParameters | None):
|
|
headers = self._get_base_headers()
|
|
|
|
body = dict(**config) if config is not None else {}
|
|
return self._request(method="post", endpoint="/realtime/client_secrets", headers=headers, body=body)
|
|
|
|
def _add_if_set(self, d: dict, key: str, value):
|
|
if value is not None:
|
|
d[key] = value
|
|
|
|
def _get_base_headers(self) -> dict[str, str]:
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': f'Bearer {self._get_api_token()}',
|
|
}
|
|
|
|
def _get_api_token(self):
|
|
provider_config = {
|
|
"openai": {
|
|
"config_key": "ai.openai_key",
|
|
"env_var": "ODOO_AI_CHATGPT_TOKEN",
|
|
},
|
|
"google": {
|
|
"config_key": "ai.google_key",
|
|
"env_var": "ODOO_AI_GEMINI_TOKEN",
|
|
},
|
|
}
|
|
config = provider_config.get(self.provider)
|
|
if config is None:
|
|
raise UserError(_("Unsupported provider '%s'", self.provider))
|
|
|
|
if api_key := self.env["ir.config_parameter"].sudo().get_param(config["config_key"]) or os.getenv(config["env_var"]):
|
|
return api_key
|
|
|
|
raise UserError(_("No API key set for provider '%s'", self.provider))
|
|
|
|
def _request(
|
|
self, method: str, endpoint: str, headers: dict[str, str], body: dict,
|
|
data: dict | None = None, files: dict | None = None, params: dict | None = None,
|
|
base_url: str | None = None, timeout: int = 30
|
|
) -> dict:
|
|
route = f"{base_url or self.base_url}/{endpoint.strip('/')}"
|
|
try:
|
|
response = requests.request(
|
|
method,
|
|
route,
|
|
params=params,
|
|
headers=headers,
|
|
json=body,
|
|
data=data,
|
|
timeout=timeout,
|
|
files=files
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except requests.exceptions.RequestException as e:
|
|
error = repr(e)
|
|
if e.response is not None:
|
|
try:
|
|
response = e.response.json()
|
|
if isinstance(response, list) and response:
|
|
# Gemini return error in a list
|
|
response = response[0]
|
|
if isinstance(response, dict) and (json_error := response.get('error', {}).get('message')):
|
|
error = json_error
|
|
else:
|
|
error = json.dumps(response, indent=2)
|
|
except ValueError: # catch JSON decode errors
|
|
error = e.response.text
|
|
if not error:
|
|
error = repr(e)
|
|
|
|
_logger.warning("LLM API request failed: %s", error)
|
|
raise UserError(error)
|
|
|
|
def _request_llm_openai(
|
|
self, llm_model, system_prompts, user_prompts, tools=None,
|
|
files=None, schema=None, temperature=0.2, inputs=(), web_grounding=False
|
|
):
|
|
"""Make a single request to the LLM.
|
|
|
|
> https://platform.openai.com/docs/guides/responses-vs-chat-completions#why-the-responses-api
|
|
> https://platform.openai.com/docs/guides/pdf-files?api-mode=responses
|
|
> https://platform.openai.com/docs/guides/function-calling?api-mode=responses
|
|
|
|
Return:
|
|
- a list of responses
|
|
- a list of tuple of the tools to call
|
|
[(tool_name, call_id, {argument_1: True, argument_2: 3})]
|
|
- a list of inputs to include in the next call in addition to the tool response
|
|
"""
|
|
user_content = [{"type": "input_text", "text": prompt} for prompt in user_prompts]
|
|
|
|
if files:
|
|
def _build_file(idx, file):
|
|
if file["mimetype"] == "text/plain":
|
|
return {"type": "input_text", "text": file["value"]}
|
|
|
|
file_uri = f"data:{file['mimetype']};base64,{file['value']}"
|
|
if file['mimetype'] == 'application/pdf':
|
|
return {
|
|
"type": "input_file",
|
|
"filename": f"file_{idx}.pdf",
|
|
"file_data": file_uri,
|
|
}
|
|
|
|
assert file["mimetype"].startswith("image/")
|
|
return {"type": "input_image", "image_url": file_uri, "detail": "low"}
|
|
|
|
user_content.extend(
|
|
_build_file(idx, file)
|
|
for idx, file in enumerate(files, start=1)
|
|
)
|
|
|
|
body = {
|
|
"model": llm_model,
|
|
"input": [
|
|
{
|
|
"role": "system",
|
|
"content": [
|
|
{"type": "input_text", "text": prompt}
|
|
for prompt in system_prompts
|
|
],
|
|
},
|
|
{"role": "user", "content": user_content},
|
|
*inputs,
|
|
],
|
|
"store": False,
|
|
}
|
|
if llm_model not in ('gpt-5', 'gpt-5-mini'):
|
|
# temperature in not supported with openai reasoning models
|
|
body["temperature"] = temperature
|
|
|
|
if schema:
|
|
body["text"] = {
|
|
"format": {
|
|
"type": "json_schema",
|
|
"name": "json_schema",
|
|
"schema": schema,
|
|
"strict": True,
|
|
},
|
|
}
|
|
|
|
if tools:
|
|
body["tools"] = self._to_open_ai_tool_schema([{
|
|
"description": tool_description,
|
|
"parameters": tool_parameter_schema,
|
|
"type": "function",
|
|
"name": tool_name,
|
|
"strict": True,
|
|
} for tool_name, (tool_description, __, __, tool_parameter_schema) in tools.items()])
|
|
body["parallel_tool_calls"] = True
|
|
|
|
if web_grounding:
|
|
search_tool = {
|
|
'type': 'web_search_preview',
|
|
}
|
|
if country_code := self.env.company.country_id.code:
|
|
search_tool['user_location'] = {
|
|
'type': 'approximate',
|
|
'country': country_code,
|
|
}
|
|
if city := self.env.company.city:
|
|
search_tool['user_location']['city'] = city
|
|
body.setdefault("tools", []).append(search_tool)
|
|
|
|
with api_call_logging(body["input"], tools) as record_response:
|
|
response, to_call, next_inputs, request_token_usage = self._request_llm_openai_helper(body, tools, inputs)
|
|
if record_response:
|
|
record_response(to_call, response, request_token_usage)
|
|
return response, to_call, next_inputs
|
|
|
|
def _request_llm_openai_helper(self, body, tools=None, inputs=()):
|
|
llm_response = self._request(
|
|
method="post",
|
|
endpoint="/responses",
|
|
headers=self._get_base_headers(),
|
|
body=body,
|
|
)
|
|
|
|
to_call = []
|
|
response = []
|
|
next_inputs = list(inputs or ())
|
|
|
|
output_lines = llm_response.get("output") or []
|
|
has_tool_calls = any(line.get('type') == 'function_call' for line in output_lines)
|
|
|
|
for line in output_lines:
|
|
if line.get('type') == 'function_call':
|
|
tool_name = line.get("name", "")
|
|
|
|
try:
|
|
arguments = json.loads(line.get("arguments") or "")
|
|
except json.decoder.JSONDecodeError:
|
|
_logger.error("AI: Malformed arguments: %s", line)
|
|
continue
|
|
|
|
to_call.append((tool_name, line.get('call_id'), arguments))
|
|
next_inputs.append(line)
|
|
|
|
elif not has_tool_calls:
|
|
if text := line.get('text'):
|
|
response.append(text)
|
|
elif line.get('type') == 'message':
|
|
response.extend(t for c in line.get('content', ()) if (t := c.get('text')))
|
|
|
|
request_token_usage = {}
|
|
if usage := llm_response.get('usage'):
|
|
request_token_usage["input_tokens"] = usage.get("input_tokens", 0)
|
|
request_token_usage["cached_tokens"] = usage.get('input_tokens_details', {}).get('cached_tokens', 0)
|
|
request_token_usage["output_tokens"] = usage.get("output_tokens", 0)
|
|
|
|
return response, to_call, next_inputs, request_token_usage
|
|
|
|
def _request_llm_google(
|
|
self, llm_model, system_prompts, user_prompts, tools=None,
|
|
files=None, schema=None, temperature=0.2, inputs=(), web_grounding=False,
|
|
):
|
|
"""Make a single request to the LLM.
|
|
|
|
Gemini's OpenAI conversion layer does not support `type: file`, so we use the real API
|
|
> https://discuss.ai.google.dev/t/combining-openai-compatible-gemini-completions-with-file-uploads/75281/2
|
|
|
|
> https://ai.google.dev/gemini-api/docs/text-generation
|
|
> https://ai.google.dev/gemini-api/docs/function-calling
|
|
> https://ai.google.dev/gemini-api/docs/document-processing
|
|
"""
|
|
if (tools or web_grounding) and schema:
|
|
# https://discuss.ai.google.dev/t/why-is-using-a-response-schema-not-supported-when-using-grounded-search/92327
|
|
raise NotImplementedError("Gemini does not support structured output with tools")
|
|
if web_grounding and tools:
|
|
# https://ai.google.dev/gemini-api/docs/function-calling?example=meeting#native-tools
|
|
# see note, live api feature only for the moment
|
|
raise NotImplementedError("Gemini does not support tools with web grounding")
|
|
body = {
|
|
"contents": [],
|
|
"generationConfig": {
|
|
"temperature": temperature,
|
|
},
|
|
}
|
|
if system_prompts:
|
|
body["systemInstruction"] = {
|
|
"parts": [
|
|
{"text": prompt}
|
|
for prompt in system_prompts
|
|
],
|
|
}
|
|
if user_prompts:
|
|
body["contents"].append({
|
|
"role": "user",
|
|
"parts": [
|
|
{"text": prompt}
|
|
for prompt in user_prompts
|
|
],
|
|
})
|
|
|
|
body["contents"].extend(inputs)
|
|
|
|
if files:
|
|
def _build_file(idx, file):
|
|
if file["mimetype"] == "text/plain":
|
|
return {"text": file["value"]}
|
|
|
|
return {"inline_data": {"mime_type": file['mimetype'], "data": file["value"]}}
|
|
|
|
body["contents"].append({"role": "user", "parts":
|
|
[_build_file(idx, file) for idx, file in enumerate(files, start=1)]})
|
|
|
|
if schema:
|
|
body["generationConfig"]["responseMimeType"] = "application/json"
|
|
body["generationConfig"]["responseJsonSchema"] = schema
|
|
|
|
if tools:
|
|
body["tools"] = {
|
|
"functionDeclarations": [{
|
|
"description": tool_description,
|
|
"parameters": tool_parameter_schema,
|
|
"name": tool_name,
|
|
} for tool_name, (tool_description, __, __, tool_parameter_schema) in tools.items()]
|
|
}
|
|
if web_grounding:
|
|
body["tools"] = {'google_search': {}}
|
|
|
|
if llm_model == "gemini-2.5-flash":
|
|
# from testing, increasing thinking budget results in the LLM actually replying ¯\_(ツ)_/¯
|
|
body['generationConfig']["thinkingConfig"] = {
|
|
"thinkingBudget": 512,
|
|
}
|
|
|
|
with api_call_logging(body["contents"], tools) as record_response:
|
|
response = None
|
|
to_call = []
|
|
next_inputs = inputs
|
|
request_token_usage = {}
|
|
for attempt in range(3):
|
|
response, to_call, next_inputs, request_token_usage = self._request_llm_google_helper(body, llm_model, inputs)
|
|
if response or to_call:
|
|
break
|
|
_logger.warning("Gemini failed to generate a response, retrying...")
|
|
if not (response or to_call):
|
|
response = "Error: failed to generate a response, try again later."
|
|
if record_response:
|
|
record_response(to_call, response, request_token_usage)
|
|
return response, to_call, next_inputs
|
|
|
|
def _request_llm_google_helper(self, body, llm_model, inputs=()):
|
|
llm_response = self._request(
|
|
method="post",
|
|
base_url="https://generativelanguage.googleapis.com/v1beta",
|
|
headers={"x-goog-api-key": self._get_api_token()},
|
|
endpoint=f"/models/{llm_model}:generateContent",
|
|
params={},
|
|
body=body,
|
|
)
|
|
|
|
to_call = []
|
|
response = []
|
|
next_inputs = list(inputs or ())
|
|
|
|
candidates = llm_response.get("candidates") or []
|
|
has_tool_calls = any(
|
|
part.get('functionCall')
|
|
for candidate in candidates
|
|
for part in candidate.get('content', {}).get('parts') or []
|
|
)
|
|
|
|
for candidate in candidates:
|
|
for line in candidate.get('content', {}).get('parts') or ():
|
|
if f_info := line.get('functionCall'):
|
|
to_call.append((f_info['name'], f_info['name'], f_info['args']))
|
|
next_inputs.append({"role": "model", "parts": [line]})
|
|
elif not has_tool_calls:
|
|
if r := line.get('text'):
|
|
response.append(r)
|
|
else:
|
|
_logger.warning("Gemini: could not parse %s", line)
|
|
|
|
request_token_usage = {}
|
|
if usage := llm_response.get("usageMetadata"):
|
|
request_token_usage["input_tokens"] = usage.get("promptTokenCount", 0)
|
|
request_token_usage["cached_tokens"] = usage.get("cachedContentTokenCount", 0)
|
|
request_token_usage["output_tokens"] = usage.get("candidatesTokenCount", 0)
|
|
|
|
return response, to_call, next_inputs, request_token_usage
|
|
|
|
def _request_llm(self, *args, **kwargs):
|
|
model = kwargs.get("llm_model") or args[0]
|
|
check_model_depreciation(self.env, model)
|
|
|
|
if self.provider == 'openai':
|
|
return self._request_llm_openai(*args, **kwargs)
|
|
|
|
if self.provider == 'google':
|
|
return self._request_llm_google(*args, **kwargs)
|
|
|
|
raise NotImplementedError()
|
|
|
|
def request_llm(
|
|
self, llm_model: str, system_prompts: list[str], user_prompts: list[str],
|
|
tools: dict[str, tuple[str, bool, Callable[[dict[str, Any]], Any], dict]] | None = None,
|
|
files: list[dict] | None = None, schema: dict | None = None, temperature: float = 0.2,
|
|
inputs: list[dict] | None = None, web_grounding: bool = False,
|
|
) -> list[str]:
|
|
"""Same as `_request_llm`, but will call the tools until we are done.
|
|
|
|
>>> files = [
|
|
>>> {'mimetype': 'text/plain', 'value': 'text content', 'file_ref': '<file_#1>'},
|
|
>>> {'mimetype': 'image/png', 'value': 'aW1hZ2UgY29udGVudA==', 'file_ref': '<file_#2>'},
|
|
>>> {'mimetype': 'application/pdf', 'value': 'cGRmIGNvbnRlbnQ=', 'file_ref': '<file_#3>'},
|
|
>>> ]
|
|
|
|
>>> tools = {
|
|
>>> "function_1": (
|
|
>>> "This function compute a sum",
|
|
>>> lambda arguments: arguments['a'] + arguments['b'],
|
|
>>> json_schema,
|
|
>>> ),
|
|
>>> "function_2": ...
|
|
>>> }
|
|
> https://json-schema.org/
|
|
"""
|
|
check_model_depreciation(self.env, llm_model)
|
|
with ai_response_logging(llm_model):
|
|
return self._request_llm_silent(
|
|
llm_model=llm_model,
|
|
system_prompts=system_prompts,
|
|
user_prompts=user_prompts,
|
|
tools=tools,
|
|
files=files,
|
|
schema=schema,
|
|
temperature=temperature,
|
|
inputs=inputs,
|
|
web_grounding=web_grounding,
|
|
)
|
|
|
|
def _request_llm_silent(
|
|
self, llm_model: str, system_prompts: list[str], user_prompts: list[str],
|
|
tools: dict[str, tuple[str, bool, Callable[[dict[str, Any]], Any], dict]] | None = None,
|
|
files: list[dict] | None = None, schema: dict | None = None, temperature: float = 0.2,
|
|
inputs: list[dict] | None = None, web_grounding: bool = False,
|
|
):
|
|
"""Wraps the `_request_llm` method to handle multiple calls and tool execution."""
|
|
AI_MAX_SUCCESSIVE_CALLS = int(self.env["ir.config_parameter"].sudo()
|
|
.get_param("ai.max_successive_calls", "20"))
|
|
|
|
AI_MAX_TOOL_CALLS_PER_CALL = int(self.env["ir.config_parameter"].sudo()
|
|
.get_param("ai.max_tool_calls_per_call", "20"))
|
|
|
|
if tools:
|
|
tools = copy.deepcopy(tools)
|
|
for __, allow_end_message, __, tool_parameter_schema in tools.values():
|
|
if allow_end_message and "__end_message" not in tool_parameter_schema["properties"]:
|
|
tool_parameter_schema["properties"]["__end_message"] = {
|
|
"type": "string",
|
|
"description": "If you are not waiting a result and you are done, write here your last message (it must follow the instructions). If you will do an action after this one, leave it empty.",
|
|
}
|
|
if "__end_message" in tool_parameter_schema["properties"] and "__end_message" not in tool_parameter_schema["required"]:
|
|
tool_parameter_schema["required"].append("__end_message")
|
|
|
|
inputs = inputs or []
|
|
|
|
if self.provider == 'google':
|
|
# OpenAI / Odoo inputs -> Gemini
|
|
inputs = [
|
|
{"role": "user" if i["role"] == "user" else "model", "parts": [{"text": i["content"]}]}
|
|
for i in inputs
|
|
]
|
|
|
|
all_responses = []
|
|
for api_call in range(AI_MAX_SUCCESSIVE_CALLS):
|
|
responses, next_actions, inputs = self._request_llm(
|
|
llm_model,
|
|
system_prompts,
|
|
user_prompts,
|
|
files=files,
|
|
inputs=inputs,
|
|
schema=schema,
|
|
tools=tools,
|
|
temperature=temperature,
|
|
web_grounding=web_grounding,
|
|
)
|
|
all_responses.extend(responses)
|
|
|
|
if not next_actions:
|
|
break
|
|
|
|
done = False
|
|
session = get_ai_logging_session()
|
|
|
|
if session:
|
|
session["tool_calls"] += min(len(next_actions), AI_MAX_TOOL_CALLS_PER_CALL)
|
|
|
|
for i, (tool_name, call_id, arguments) in enumerate(next_actions):
|
|
if i >= AI_MAX_TOOL_CALLS_PER_CALL:
|
|
_logger.warning("AI: Tool call limit reached, stopping further tool calls")
|
|
inputs.append(self._build_tool_call_response(call_id, "Error: This tool call isn't processed because of tool call limit, try again"))
|
|
continue
|
|
|
|
if tool_name not in tools:
|
|
_logger.error("AI: Try to call a forbidden action %s", tool_name)
|
|
inputs.append(self._build_tool_call_response(call_id, f"Error: unknown tool '{tool_name}'. Try again with the correct tool name."))
|
|
continue
|
|
|
|
has_end_message = "__end_message" in arguments
|
|
end_message = arguments.pop("__end_message", None)
|
|
result, error = tools[tool_name][2](arguments=arguments)
|
|
|
|
inputs.append(self._build_tool_call_response(call_id, result))
|
|
|
|
if has_end_message and error is None:
|
|
done = True
|
|
if end_response := end_message and end_message.strip():
|
|
all_responses.append(end_response)
|
|
_logger.info("AI: action terminate early: %s", end_response)
|
|
else:
|
|
_logger.info("AI: action terminate early with empty message")
|
|
|
|
if session and len(next_actions) > 1: # Batch of tool calls
|
|
_logger.debug("[AI Tool Summary] Batch #%d completed, %d tool calls", session["current_batch_id"], len(next_actions))
|
|
|
|
if done:
|
|
break
|
|
|
|
_logger.info("AI: API calls %s", api_call + 1)
|
|
|
|
if not all_responses:
|
|
error_msg = "Processing loop ended with no response."
|
|
if api_call + 1 >= AI_MAX_SUCCESSIVE_CALLS:
|
|
error_msg = "Number of successive API calls exceeded, please try again with a more precise request."
|
|
raise ValueError(error_msg)
|
|
|
|
return all_responses
|
|
|
|
def _to_open_ai_tool_schema(self, schema):
|
|
"""Convert the tool schema if needed.
|
|
|
|
Open AI `responses` endpoints needs all parameters to be in the
|
|
"required" list, but it accepts `"type": ["string", "null"]`.
|
|
|
|
So we convert the base JSON schema to the array version if needed.
|
|
"""
|
|
if self.provider != "openai":
|
|
return schema
|
|
|
|
for tool in schema:
|
|
required = tool["parameters"]["required"]
|
|
non_required = set(tool["parameters"]["properties"]) - set(required)
|
|
for name in non_required:
|
|
tool["parameters"]["properties"][name]["type"] = [tool["parameters"]["properties"][name]["type"], "null"]
|
|
tool["parameters"]["required"].extend(non_required)
|
|
tool["parameters"]["additionalProperties"] = False
|
|
return schema
|
|
|
|
def _build_tool_call_response(self, tool_call_id, return_value):
|
|
"""Build the response for the given tool call.
|
|
|
|
:param tool_call_id: The identifier of the tool call
|
|
:param return_value: The value the tool returned
|
|
"""
|
|
if self.provider == "openai":
|
|
return {
|
|
"type": "function_call_output",
|
|
"call_id": tool_call_id,
|
|
"output": str(return_value),
|
|
}
|
|
|
|
if self.provider == "google":
|
|
return {
|
|
"role": "user",
|
|
"parts": [{
|
|
"functionResponse": {
|
|
"name": tool_call_id,
|
|
"response": {"result": str(return_value)},
|
|
},
|
|
}],
|
|
}
|
|
|
|
raise NotImplementedError(f"Unsupported provider: {self.provider}")
|