diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5a247..b3efdf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.8.0] - 2026-02-02 +### Added +- pydantic to requirements +- add AbstractAIService +- add AbstractWebSearchService +- MCP server interface +### Updated +- OpenAI dependency + ## [1.7.3] - 2026-01-29 ### Added - Tavily service constants @@ -63,7 +72,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.6.21] - 2023-10-21 ### Added -[Constant] add CONIG_LLM_CUSTOM_BASE_URL +[Constant] add CONFIG_LLM_CUSTOM_BASE_URL ## [1.6.20] - 2023-10-07 ### Added diff --git a/README.md b/README.md index 96557e0..236f59a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# OctoBot-Services [1.7.3](https://github.com/Drakkar-Software/OctoBot-Services/tree/master/docs/CHANGELOG.md) +# OctoBot-Services [1.8.0](https://github.com/Drakkar-Software/OctoBot-Services/tree/master/docs/CHANGELOG.md) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/31a1caa6e5384d80bf890dba5c9b5e4b)](https://app.codacy.com/gh/Drakkar-Software/OctoBot-Services?utm_source=github.com&utm_medium=referral&utm_content=Drakkar-Software/OctoBot-Services&utm_campaign=Badge_Grade_Dashboard) [![PyPI](https://img.shields.io/pypi/v/OctoBot-Services.svg)](https://pypi.python.org/pypi/OctoBot-Services/) [![Github-Action-CI](https://github.com/Drakkar-Software/OctoBot-Services/workflows/OctoBot-Services-CI/badge.svg)](https://github.com/Drakkar-Software/OctoBot-Services/actions) diff --git a/full_requirements.txt b/full_requirements.txt index b9f56ce..d4c5c7a 100644 --- a/full_requirements.txt +++ b/full_requirements.txt @@ -38,11 +38,12 @@ gevent==25.5.1 ### used by flask-socketio with gevent (listed here because multiple libs are usable, force this one) gevent-websocket==0.10.1 flask-socketio==5.5.1 -# chatgpt -openai==1.99.9 - +# openai +openai==2.15.0 +# agents +pydantic==2.12.5 +mcp==1.26.0 # Coingecko coingecko-openapi-client>=1.3.0 - # Analysis tools vaderSentiment==3.3.2 diff --git a/octobot_services/__init__.py b/octobot_services/__init__.py index f337073..8ac4299 100644 --- a/octobot_services/__init__.py +++ b/octobot_services/__init__.py @@ -15,4 +15,4 @@ # License along with this library. PROJECT_NAME = "OctoBot-Services" -VERSION = "1.7.3" # major.minor.revision +VERSION = "1.8.0" # major.minor.revision diff --git a/octobot_services/api/__init__.py b/octobot_services/api/__init__.py index 8655642..df7d516 100644 --- a/octobot_services/api/__init__.py +++ b/octobot_services/api/__init__.py @@ -21,6 +21,12 @@ from octobot_services.api.services import ( get_available_services, + get_available_backtestable_services, + get_available_ai_services, + get_available_web_search_services, + get_ai_service, + get_web_search_service, + is_service_available_in_backtesting, get_service, create_service_factory, stop_services, @@ -39,6 +45,8 @@ from octobot_services.api.service_feeds import ( create_service_feed_factory, get_service_feed, + get_available_backtestable_feeds, + is_service_used_by_backtestable_feed, start_service_feed, stop_service_feed, clear_bot_id_feeds, @@ -59,6 +67,12 @@ __all__ = [ "get_available_services", + "get_available_backtestable_services", + "get_available_ai_services", + "get_available_web_search_services", + "get_ai_service", + "get_web_search_service", + "is_service_available_in_backtesting", "get_service", "create_service_factory", "stop_services", @@ -73,6 +87,8 @@ "stop_interfaces", "create_service_feed_factory", "get_service_feed", + "get_available_backtestable_feeds", + "is_service_used_by_backtestable_feed", "start_service_feed", "stop_service_feed", "clear_bot_id_feeds", diff --git a/octobot_services/api/interfaces.py b/octobot_services/api/interfaces.py index 72264d3..cadf565 100644 --- a/octobot_services/api/interfaces.py +++ b/octobot_services/api/interfaces.py @@ -19,6 +19,7 @@ import async_channel.channels as channels import octobot_services.interfaces as interfaces import octobot_services.managers as managers +import octobot_services.api.service_feeds as service_feeds_api def initialize_global_project_data(bot_api: object, project_name: str, project_version: str) -> None: @@ -53,7 +54,12 @@ async def send_user_command(bot_id, subject, action, data, wait_for_processing=F def is_enabled_in_backtesting(interface_class) -> bool: - return all(service.BACKTESTING_ENABLED for service in interface_class.REQUIRED_SERVICES) + if not interface_class.REQUIRED_SERVICES: + return True + return all( + service_feeds_api.is_service_used_by_backtestable_feed(service) + for service in interface_class.REQUIRED_SERVICES + ) def is_interface_relevant(config, interface_class, backtesting_enabled): diff --git a/octobot_services/api/service_feeds.py b/octobot_services/api/service_feeds.py index 0aa0fdd..f0ff78e 100644 --- a/octobot_services/api/service_feeds.py +++ b/octobot_services/api/service_feeds.py @@ -13,10 +13,25 @@ # # You should have received a copy of the GNU Lesser General Public # License along with this library. +import octobot_commons.tentacles_management as tentacles_management + import octobot_services.managers as managers import octobot_services.service_feeds as service_feeds +def get_available_backtestable_feeds() -> list: + feeds = tentacles_management.get_all_classes_from_parent(service_feeds.AbstractServiceFeed) + return [feed for feed in feeds if feed.BACKTESTING_ENABLED] + + +def is_service_used_by_backtestable_feed(service_class) -> bool: + backtestable = get_available_backtestable_feeds() + for feed in backtestable: + if feed.REQUIRED_SERVICES and service_class in feed.REQUIRED_SERVICES: + return True + return False + + def create_service_feed_factory(config, main_async_loop, bot_id) -> service_feeds.ServiceFeedFactory: return service_feeds.ServiceFeedFactory(config, main_async_loop, bot_id) diff --git a/octobot_services/api/services.py b/octobot_services/api/services.py index c467f3f..3c5fdee 100644 --- a/octobot_services/api/services.py +++ b/octobot_services/api/services.py @@ -13,6 +13,7 @@ # # You should have received a copy of the GNU Lesser General Public # License along with this library. +import octobot_services.api.service_feeds as service_feeds_api import octobot_services.managers as managers import octobot_services.services as services import octobot_services.interfaces as interfaces @@ -31,9 +32,57 @@ def _service_async_lock(service_class): return _SERVICE_ASYNC_LOCKS[service_class.__name__] -def get_available_services() -> list: +def get_available_services() -> list[type[services.AbstractService]]: return services.ServiceFactory.get_available_services() +def get_available_ai_services() -> list[type[services.AbstractAIService]]: + return services.ServiceFactory.get_available_ai_services() + +def get_available_web_search_services() -> list[type[services.AbstractWebSearchService]]: + return services.ServiceFactory.get_available_web_search_services() + + +def get_available_backtestable_services() -> list: + return [ + service_class for service_class in services.ServiceFactory.get_available_services() + if service_class.BACKTESTING_ENABLED + ] + +async def _get_available_service_instance( + get_available_services_func, + service_type_name: str, + is_backtesting: bool = False +): + available_services = get_available_services_func() + for service_class in available_services: + try: + return await get_service(service_class, is_backtesting, None) + except errors.CreationError: + # Service is not running/initialized, skip it + continue + raise errors.CreationError(f"No {service_type_name} is currently running or available.") + +async def get_ai_service(is_backtesting=False) -> services.AbstractAIService: + return await _get_available_service_instance( + get_available_ai_services, + "AI service", + is_backtesting + ) + +async def get_web_search_service(is_backtesting=False) -> services.AbstractWebSearchService: + return await _get_available_service_instance( + get_available_web_search_services, + "web search service", + is_backtesting + ) + + +def is_service_available_in_backtesting(service_class) -> bool: + return ( + service_class.BACKTESTING_ENABLED + or service_feeds_api.is_service_used_by_backtestable_feed(service_class) + ) + async def get_service(service_class, is_backtesting, config=None): # prevent concurrent access when creating a service @@ -47,7 +96,7 @@ async def get_service(service_class, is_backtesting, config=None): ) if created: service = service_class.instance() - if is_backtesting and not service.BACKTESTING_ENABLED: + if is_backtesting and not is_service_available_in_backtesting(service_class): raise errors.UnavailableInBacktestingError( f"{service_class.__name__} service is not available in backtesting" ) diff --git a/octobot_services/constants.py b/octobot_services/constants.py index 9338930..4e28e15 100644 --- a/octobot_services/constants.py +++ b/octobot_services/constants.py @@ -82,11 +82,26 @@ CONFIG_OPENAI_SECRET_KEY = "openai-secret-key" CONFIG_LLM_CUSTOM_BASE_URL = "llm-custom-base-url" CONFIG_LLM_MODEL = "llm-model" +CONFIG_LLM_MODEL_FAST = "llm-model-fast" +CONFIG_LLM_MODEL_REASONING = "llm-model-reasoning" CONFIG_LLM_DAILY_TOKENS_LIMIT = "llm-daily-tokens-limit" +CONFIG_LLM_SHOW_REASONING = "llm-show-reasoning" +CONFIG_LLM_REASONING_EFFORT = "llm-reasoning-effort" +CONFIG_LLM_MCP_SERVERS = "llm-mcp-servers" +CONFIG_LLM_AUTO_INJECT_MCP_TOOLS = "llm-auto-inject-mcp-tools" ENV_OPENAI_SECRET_KEY = "OPENAI_SECRET_KEY" ENV_GPT_MODEL = "GPT_MODEL" ENV_GPT_DAILY_TOKENS_LIMIT = "GPT_DAILY_TOKEN_LIMIT" +# MCP +CONFIG_MCP = "mcp" +CONFIG_MCP_IP = "ip" +CONFIG_MCP_PORT = "port" +ENV_MCP_PORT = "MCP_PORT" +ENV_MCP_ADDRESS = "MCP_ADDRESS" +DEFAULT_MCP_IP = '127.0.0.1' +DEFAULT_MCP_PORT = 3001 + # Google CONFIG_GOOGLE = "google" CONFIG_TREND_TOPICS = "trends" @@ -123,6 +138,16 @@ CONFIG_TAVILY_API_KEY = "api-key" CONFIG_TAVILY_PROJECT_ID = "project-id" +# SearXNG (self-hosted web search) +CONFIG_SEARXNG = "searxng" +CONFIG_SEARXNG_URL = "url" +CONFIG_SEARXNG_PORT = "port" +CONFIG_SEARXNG_CATEGORIES = "categories" +CONFIG_SEARXNG_LANGUAGE = "language" +CONFIG_SEARXNG_TIME_RANGE = "time_range" +CONFIG_SEARXNG_SAFE_SEARCH = "safe_search" +CONFIG_SEARXNG_ENGINES = "engines" + # Reddit CONFIG_REDDIT = "reddit" CONFIG_REDDIT_SUBREDDITS = "subreddits" diff --git a/octobot_services/enums.py b/octobot_services/enums.py index 09320b0..eb791f2 100644 --- a/octobot_services/enums.py +++ b/octobot_services/enums.py @@ -42,3 +42,8 @@ class ReadOnlyInfoType(enum.Enum): CLICKABLE = "clickable" CTA = "cta" READONLY = "readonly" + + +class AIModelPolicy(enum.Enum): + FAST = "fast" + REASONING = "reasoning" diff --git a/octobot_services/service_feeds/abstract_service_feed.py b/octobot_services/service_feeds/abstract_service_feed.py index c1c79cd..b83cd67 100644 --- a/octobot_services/service_feeds/abstract_service_feed.py +++ b/octobot_services/service_feeds/abstract_service_feed.py @@ -36,6 +36,9 @@ class AbstractServiceFeed(abstract_service_user.AbstractServiceUser, # Set simulator class when available in order to use it in backtesting for this feed SIMULATOR_CLASS = None + # Whether this feed supports historical data collection for backtesting + BACKTESTING_ENABLED = False + _SLEEPING_TIME_BEFORE_RECONNECT_ATTEMPT_SEC = 10 DELAY_BETWEEN_STREAMS_QUERIES = 5 REQUIRED_SERVICE_ERROR_MESSAGE = "Required services are not ready, service feed can't start" @@ -145,3 +148,39 @@ async def stop(self): if self.is_running: self.should_stop = True self.is_running = False + + async def get_historical_data( + self, + start_timestamp, + end_timestamp, + symbols=None, + source=None, + **kwargs + ) -> typing.AsyncIterator[list[dict]]: + """ + Fetch historical data from the feed for the given time range. + Override this method in feeds that support historical data collection. + + :param start_timestamp: milliseconds timestamp (int/float) for start of range + :param end_timestamp: milliseconds timestamp (int/float) for end of range + :param symbols: optional list of symbols to filter by + :param source: optional source/topic to fetch + :param kwargs: additional feed-specific parameters + :return: async generator yielding batches (lists) of event dicts + :rtype: typing.AsyncIterator[list[dict]] + + Each event dict should have at least: + - timestamp: milliseconds timestamp (int/float) + - payload: dict with event data + - channel: optional str + - symbol: optional str + """ + raise NotImplementedError("get_historical_data is not implemented for this feed") + + @classmethod + def get_historical_sources(cls) -> list: + """ + Return the list of source/topic ids supported by get_historical_data. + Override in feeds that support historical data to return their source ids. + """ + return [] diff --git a/octobot_services/services/__init__.py b/octobot_services/services/__init__.py index 17cd4d6..88aab33 100644 --- a/octobot_services/services/__init__.py +++ b/octobot_services/services/__init__.py @@ -16,6 +16,8 @@ from octobot_services.services import service_factory from octobot_services.services import abstract_service +from octobot_services.services import abstract_ai_service +from octobot_services.services import abstract_web_search_service from octobot_services.services import read_only_info from octobot_services.services.service_factory import ( @@ -24,6 +26,14 @@ from octobot_services.services.abstract_service import ( AbstractService, ) +from octobot_services.services.abstract_ai_service import ( + AbstractAIService, +) +from octobot_services.services.abstract_web_search_service import ( + AbstractWebSearchService, + WebSearchResult, + WebSearchResponse, +) from octobot_services.services.read_only_info import ( ReadOnlyInfo, ) @@ -31,5 +41,9 @@ __all__ = [ "ServiceFactory", "AbstractService", + "AbstractAIService", + "AbstractWebSearchService", + "WebSearchResult", + "WebSearchResponse", "ReadOnlyInfo", ] diff --git a/octobot_services/services/abstract_ai_service.py b/octobot_services/services/abstract_ai_service.py new file mode 100644 index 0000000..ee05096 --- /dev/null +++ b/octobot_services/services/abstract_ai_service.py @@ -0,0 +1,365 @@ +# Drakkar-Software OctoBot-Services +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import abc +import asyncio +import functools +import json +import logging +import typing + +from octobot_services.services.abstract_service import AbstractService + +class AbstractAIService(AbstractService, abc.ABC): + DEFAULT_MODEL: typing.Optional[str] = None + DEFAULT_MAX_TOKENS: int = 10000 + DEFAULT_TEMPERATURE: float = 0.5 + + def __init__(self): + super().__init__() + self.model = self.DEFAULT_MODEL + self.models: list[str] = [] + self.models_config: typing.Dict[str, str] = {} # usage policy -> model name, e.g. {"fast": "gpt-4o-mini", "reasoning": "o4-mini"} + + @staticmethod + def retry_llm_completion( + max_retries: int = 3, + retry_delay: float = 0.0, + retriable_exceptions: tuple = (json.JSONDecodeError, ValueError, KeyError, AttributeError), + ): + """ + Decorator to retry LLM completion methods on retriable exceptions. + + Args: + max_retries: Maximum number of retry attempts (default: 3). + retry_delay: Delay in seconds between retries (default: 0.0). + retriable_exceptions: Tuple of exception types that should trigger retries. + + Returns: + Decorator function that wraps async methods with retry logic. + """ + def decorator(func): + @functools.wraps(func) + async def wrapper(self, *args, **kwargs): + logger = getattr(self, 'logger', None) or logging.getLogger(f"{self.__class__.__name__}.retry") + last_exception = None + + for attempt in range(1, max_retries + 1): + try: + return await func(self, *args, **kwargs) + except retriable_exceptions as e: + last_exception = e + error_details = str(e) + + if attempt < max_retries: + logger.warning( + f"{func.__name__} failed on attempt {attempt}/{max_retries} " + f"for {self.__class__.__name__}: {error_details}. Retrying..." + ) + if retry_delay > 0: + await asyncio.sleep(retry_delay) + else: + logger.error( + f"{func.__name__} failed on final attempt {attempt}/{max_retries} " + f"for {self.__class__.__name__}: {error_details}" + ) + raise + except Exception: + # Non-retriable exceptions should be raised immediately + raise + + # Should not reach here, but just in case + if last_exception: + raise last_exception + + return wrapper + return decorator + + @retry_llm_completion() + @abc.abstractmethod + async def get_completion( + self, + messages: list, + model: typing.Optional[str] = None, + max_tokens: int = 10000, + n: int = 1, + stop: typing.Optional[typing.Union[str, list]] = None, + temperature: float = 0.5, + json_output: bool = False, + response_schema: typing.Optional[typing.Any] = None, + tools: typing.Optional[list] = None, + tool_choice: typing.Optional[typing.Union[str, dict]] = None, + use_octobot_mcp: typing.Optional[bool] = None, + ) -> typing.Union[str, dict, None]: + """ + Get a completion from the LLM. + + Args: + messages: List of message dicts with 'role' and 'content' keys. + model: Model to use (defaults to service's default model). + max_tokens: Maximum tokens in the response. + n: Number of completions to generate. + stop: Stop sequences. + temperature: Sampling temperature (0-2). + json_output: Whether to return JSON formatted output. + response_schema: Optional Pydantic model or JSON schema dict + for structured output validation. + tools: Optional list of tool definitions for function calling. + Each tool should be a dict with 'type' and 'function' keys. + tool_choice: Optional control for tool usage. Can be "auto", "none", + or a dict specifying a specific tool. + use_octobot_mcp: Optional bool to include OctoBot MCP server tools. + If True, automatically discovers and includes tools from OctoBot MCP interface. + If None, uses default behavior (does not include OctoBot MCP). + If False, explicitly excludes OctoBot MCP tools. + + Returns: + str: The completion text when no tools are used or tool_choice is "none". + dict: When tools are used and model makes tool calls, returns dict with: + - "content": str | None (may be None if only tool calls) + - "tool_calls": list of tool call dicts with id, type, function keys + None: On error + + Raises: + InvalidRequestError: If the request is malformed. + RateLimitError: If rate limits are exceeded. + """ + raise NotImplementedError("get_completion not implemented") + + @retry_llm_completion() + @abc.abstractmethod + async def get_completion_with_tools( + self, + messages: list, + tool_executor: typing.Optional[typing.Callable[[str, dict], typing.Any]] = None, + model: typing.Optional[str] = None, + max_tokens: int = 10000, + n: int = 1, + stop: typing.Optional[typing.Union[str, list]] = None, + temperature: float = 0.5, + json_output: bool = False, + response_schema: typing.Optional[typing.Any] = None, + tools: typing.Optional[list] = None, + tool_choice: typing.Optional[typing.Union[str, dict]] = None, + use_octobot_mcp: typing.Optional[bool] = None, + max_tool_iterations: int = 3, + return_tool_calls: bool = False, + ) -> typing.Any: + """ + Get a completion from the LLM with automatic tool calling orchestration. + + This method handles the tool calling loop automatically: + 1. Calls get_completion with the provided parameters + 2. If the response contains tool_calls, executes them using tool_executor + 3. Appends tool results to messages and calls get_completion again + 4. Repeats until no tool_calls are present or max_tool_iterations is reached + 5. Returns the final parsed response + + Args: + messages: List of message dicts with 'role' and 'content' keys. + tool_executor: Optional callback function to execute tools. + Signature: (tool_name: str, arguments: dict) -> Any + If None, tool calls will not be executed (response returned as-is). + model: Model to use (defaults to service's default model). + max_tokens: Maximum tokens in the response. + n: Number of completions to generate. + stop: Stop sequences. + temperature: Sampling temperature (0-2). + json_output: Whether to parse response as JSON. + response_schema: Optional Pydantic model or JSON schema dict + for structured output validation. + tools: Optional list of tool definitions for function calling. + Each tool should be a dict with 'type' and 'function' keys. + tool_choice: Optional control for tool usage. Can be "auto", "none", + or a dict specifying a specific tool. + use_octobot_mcp: Optional bool to include OctoBot MCP server tools. + If True, automatically discovers and includes tools from OctoBot MCP interface. + If None, uses default behavior (does not include OctoBot MCP). + If False, explicitly excludes OctoBot MCP tools. + max_tool_iterations: Maximum number of tool calling rounds (default: 3). + Prevents infinite loops if LLM keeps requesting tools. + + Returns: + Final parsed response: + - dict: If json_output=True, returns parsed JSON dict + - str: If json_output=False, returns the content string + - If tool_executor is None and tool_calls are present, returns dict with tool_calls + + Raises: + InvalidRequestError: If the request is malformed. + RateLimitError: If rate limits are exceeded. + ValueError: If max_tool_iterations is exceeded or tool_executor is None when tool_calls are present. + """ + raise NotImplementedError("get_completion_with_tools not implemented") + + @staticmethod + @abc.abstractmethod + def create_message( + role: str, + content: str, + model: typing.Optional[str] = None + ) -> dict: + """ + Create a message dict for the LLM. + + Some models don't support certain roles (e.g., 'system'), + so this method allows implementations to handle that. + + Args: + role: The message role ('system', 'user', 'assistant'). + content: The message content. + model: Optional model name to handle model-specific restrictions. + + Returns: + A dict with 'role' and 'content' keys. + """ + raise NotImplementedError("create_message not implemented") + + @staticmethod + def parse_completion_response( + response: typing.Union[str, dict, None], + json_output: bool = False + ) -> typing.Any: + """ + Parse a completion response from get_completion(). + + Handles both string responses and dict responses (with tool_calls). + Extracts content and optionally parses JSON. + + Args: + response: The response from get_completion(), can be str, dict, or None. + json_output: Whether to parse the content as JSON. + + Returns: + Parsed JSON dict if json_output=True, otherwise the content string. + + Raises: + json.JSONDecodeError: If json_output=True and content is not valid JSON. + ValueError: If response format is unexpected. + """ + if response is None: + raise ValueError("Response is None") + + # Extract content from response + if isinstance(response, dict): + response_stripped = response.get("content", "").strip() if response.get("content") else str(response).strip() + else: + response_stripped = response.strip() if isinstance(response, str) else str(response) + + # Parse JSON if requested + if json_output: + parsed_response = json.loads(response_stripped) + else: + parsed_response = response_stripped + + return parsed_response + + @staticmethod + @abc.abstractmethod + def handle_tool_calls( + tool_calls: typing.List[dict], + tool_executor: typing.Callable[[str, dict], typing.Any], + ) -> typing.List[dict]: + """ + Execute tool calls and format results for LLM message continuation. + + Takes a list of tool calls from an LLM response, executes them using + the provided tool_executor callback, and returns formatted tool result + messages ready to append to the conversation. + + Args: + tool_calls: List of tool call dicts from LLM response, each with: + - "id": Tool call ID + - "function": Dict with "name" and "arguments" keys + tool_executor: Callback function that executes a tool. + Signature: (tool_name: str, arguments: dict) -> Any + Should return the tool execution result (will be JSON-serialized). + + Returns: + List of tool result message dicts, each with: + - "tool_call_id": The original tool call ID + - "role": "tool" + - "name": Tool function name + - "content": JSON-serialized tool result + + Raises: + NotImplementedError: If the service doesn't support tool calls. + """ + raise NotImplementedError("handle_tool_calls not implemented") + + def format_tool_definition( + self, + name: str, + description: str, + parameters: typing.Dict[str, typing.Any], + tool_type: str = "function" + ) -> typing.Dict[str, typing.Any]: + """ + Format a tool definition into the standard OpenAI function calling format. + + This method can be overridden by concrete AI services to customize tool formatting. + Use this instead of manually creating tool dictionaries to avoid format errors. + + Args: + name: The function name (must be non-empty string). + description: Description of what the tool does. + parameters: JSON schema dict defining the tool's parameters. + tool_type: The tool type (default: "function" for OpenAI compatibility). + + Returns: + Properly formatted tool definition dict with 'type' and 'function' keys. + + Example: + >>> service.format_tool_definition( + ... name="run_agent", + ... description="Execute a specific agent", + ... parameters={"type": "object", "properties": {"agent_name": {"type": "string"}}} + ... ) + { + "type": "function", + "function": { + "name": "run_agent", + "description": "Execute a specific agent", + "parameters": {"type": "object", "properties": {"agent_name": {"type": "string"}}} + } + } + """ + if not name or not isinstance(name, str) or name.strip() == "": + raise ValueError(f"Tool name must be a non-empty string, got: {name}") + + return { + "type": tool_type, + "function": { + "name": name, + "description": description, + "parameters": parameters, + } + } + + def get_model(self) -> str: + return self.model + + def get_available_models(self) -> list: + return self.models + + def get_model_for_policy(self, policy: str) -> typing.Optional[str]: + """ + Return the model name for a given usage policy (e.g. "fast" or "reasoning"). + When models_config is set (e.g. {"fast": "gpt-4o-mini", "reasoning": "o4-mini"}), + returns the model for that policy; otherwise returns None and callers should use get_model(). + """ + if not self.models_config: + return None + return self.models_config.get(policy) diff --git a/octobot_services/services/abstract_service.py b/octobot_services/services/abstract_service.py index 22e0e8a..cd395ae 100644 --- a/octobot_services/services/abstract_service.py +++ b/octobot_services/services/abstract_service.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. import abc +import typing import octobot_commons.configuration as configuration import octobot_commons.singleton as singleton @@ -30,10 +31,10 @@ class AbstractService(singleton.Singleton): def __init__(self): super().__init__() - self.logger: logging.BotLogger = None - self.config: dict = None - self.edited_config: dict = None - self.creation_error_message: str = None + self.logger: typing.Optional[logging.BotLogger] = None + self.config: typing.Optional[dict] = None + self.edited_config: typing.Optional[dict] = None + self.creation_error_message: typing.Optional[str] = None self._created: bool = True self._healthy: bool = False self._has_been_created: bool = False diff --git a/octobot_services/services/abstract_web_search_service.py b/octobot_services/services/abstract_web_search_service.py new file mode 100644 index 0000000..c7bedee --- /dev/null +++ b/octobot_services/services/abstract_web_search_service.py @@ -0,0 +1,157 @@ +# Drakkar-Software OctoBot-Services +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import abc +import typing +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence + +from octobot_services.services.abstract_service import AbstractService + +@dataclass +class WebSearchResult: + """Single web search result.""" + title: str = "" + url: str = "" + content: str = "" + score: float = 0.0 + raw_content: Optional[str] = None + favicon: Optional[str] = None + engine: Optional[str] = None # Which search engine returned this result + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "WebSearchResult": + if not data or not isinstance(data, dict): + return cls() + return cls( + title=str(data.get("title", "")), + url=str(data.get("url", "")), + content=str(data.get("content", "")), + score=float(data.get("score", 0)), + raw_content=data.get("raw_content"), + favicon=data.get("favicon"), + engine=data.get("engine"), + ) + + +@dataclass +class WebSearchResponse: + """Web search response containing multiple results.""" + query: str = "" + results: List[WebSearchResult] = field(default_factory=list) + answer: Optional[str] = None # AI-generated answer if available + response_time: Optional[float] = None + total_results: Optional[int] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "WebSearchResponse": + if not data or not isinstance(data, dict): + return cls() + results = [ + WebSearchResult.from_dict(r) + for r in data.get("results", []) + if isinstance(r, dict) + ] + return cls( + query=str(data.get("query", "")), + results=results, + answer=data.get("answer"), + response_time=data.get("response_time"), + total_results=data.get("total_results"), + ) + + +class AbstractWebSearchService(AbstractService, abc.ABC): + """ + Abstract base class for web search services. + + Provides a common interface for web search functionality similar to + how AbstractAIService provides a common interface for AI/LLM services. + + Implementations should override the abstract methods to provide + search functionality via different backends (Tavily, SearXNG, etc.). + """ + + DEFAULT_MAX_RESULTS: int = 10 + DEFAULT_TIMEOUT: float = 30.0 + + def __init__(self): + super().__init__() + self._startup_message: str = "" + self._startup_healthy: bool = False + + @abc.abstractmethod + async def search( + self, + query: str, + max_results: Optional[int] = None, + categories: Optional[Sequence[str]] = None, + language: Optional[str] = None, + time_range: Optional[str] = None, + include_domains: Optional[Sequence[str]] = None, + exclude_domains: Optional[Sequence[str]] = None, + timeout: Optional[float] = None, + **kwargs, + ) -> WebSearchResponse: + """ + Perform a web search. + + Args: + query: The search query string. + max_results: Maximum number of results to return. + categories: Search categories (e.g., ["general", "news", "images"]). + language: Language code for results (e.g., "en", "fr"). + time_range: Time range filter (e.g., "day", "week", "month", "year"). + include_domains: Only include results from these domains. + exclude_domains: Exclude results from these domains. + timeout: Request timeout in seconds. + **kwargs: Additional provider-specific parameters. + + Returns: + WebSearchResponse containing the search results. + """ + raise NotImplementedError("search not implemented") + + async def search_news( + self, + query: str, + max_results: Optional[int] = None, + language: Optional[str] = None, + time_range: Optional[str] = None, + timeout: Optional[float] = None, + **kwargs, + ) -> WebSearchResponse: + """ + Search for news articles. + + Default implementation calls search() with categories=["news"]. + Override for providers with dedicated news search endpoints. + + Args: + query: The search query string. + max_results: Maximum number of results to return. + language: Language code for results. + time_range: Time range filter. + timeout: Request timeout in seconds. + **kwargs: Additional provider-specific parameters. + + Returns: + WebSearchResponse containing news results. + """ + raise NotImplementedError("search_news not implemented") + + def get_successful_startup_message(self) -> typing.Tuple[str, bool]: + """Return startup message and health status.""" + return self._startup_message, self._startup_healthy diff --git a/octobot_services/services/service_factory.py b/octobot_services/services/service_factory.py index 74f3df6..2bde86b 100644 --- a/octobot_services/services/service_factory.py +++ b/octobot_services/services/service_factory.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. +import typing + import octobot_commons.logging as logging import octobot_services.constants as constants @@ -27,9 +29,17 @@ def __init__(self, config): @staticmethod def get_available_services() -> list: - return [service_class for service_class in services.AbstractService.__subclasses__()] + return [service_class for service_class in services.AbstractService.__subclasses__() if service_class is not services.AbstractAIService and service_class is not services.AbstractWebSearchService] + + @staticmethod + def get_available_ai_services() -> list: + return [service_class for service_class in services.AbstractAIService.__subclasses__()] + + @staticmethod + def get_available_web_search_services() -> list: + return [service_class for service_class in services.AbstractWebSearchService.__subclasses__()] - async def create_or_get_service(self, service_class, backtesting_enabled, edited_config) -> (bool, str): + async def create_or_get_service(self, service_class, backtesting_enabled, edited_config) -> typing.Tuple[bool, str]: """ create_or_get_service will create a service instance if it doesn't exist, check the existing one otherwise :param service_class: the class of the service to create @@ -45,7 +55,6 @@ async def create_or_get_service(self, service_class, backtesting_enabled, edited ) async def _create_service(self, service, backtesting_enabled, edited_config) -> bool: - service.is_backtesting_enabled = backtesting_enabled service.set_has_been_created(True) service.logger = logging.get_logger(service.get_name()) service.config = self.config