feat!: migrate to OpenAI native tool calls and fix circular deps (#fuck-gemini)

- Fix circular dependencies in agent/tools
- Migrate from custom JSON to OpenAI tool calls format
- Add async streaming (step_stream, complete_stream)
- Simplify prompt system and remove token counting
- Add 5 new API endpoints (/health, /v1/models, /api/memory/*)
- Add 3 new tools (get_torrent_by_index, add_torrent_by_index, set_language)
- Fix all 500 tests and add coverage config (80% threshold)
- Add comprehensive docs (README, pytest guide)

BREAKING: LLM interface changed, memory injection via get_memory()
This commit is contained in:
2025-12-06 19:11:05 +01:00
parent 2c8cdd3ab1
commit 9ca31e45e0
92 changed files with 7897 additions and 1786 deletions
+3 -2
View File
@@ -1,10 +1,11 @@
"""Knaben API client."""
from .client import KnabenClient
from .dto import TorrentResult
from .exceptions import (
KnabenError,
KnabenConfigurationError,
KnabenAPIError,
KnabenConfigurationError,
KnabenError,
KnabenNotFoundError,
)
+23 -27
View File
@@ -1,12 +1,15 @@
"""Knaben torrent search API client."""
from typing import Dict, Any, Optional, List
import logging
from typing import Any
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from requests.exceptions import HTTPError, RequestException, Timeout
from agent.config import Settings, settings
from .dto import TorrentResult
from .exceptions import KnabenError, KnabenAPIError, KnabenNotFoundError
from .exceptions import KnabenAPIError, KnabenNotFoundError
logger = logging.getLogger(__name__)
@@ -26,9 +29,9 @@ class KnabenClient:
def __init__(
self,
base_url: Optional[str] = None,
timeout: Optional[int] = None,
config: Optional[Settings] = None
base_url: str | None = None,
timeout: int | None = None,
config: Settings | None = None,
):
"""
Initialize Knaben client.
@@ -48,10 +51,7 @@ class KnabenClient:
logger.info("Knaben client initialized")
def _make_request(
self,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
def _make_request(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Make a request to Knaben API.
@@ -90,11 +90,7 @@ class KnabenClient:
logger.error(f"Knaben API request failed: {e}")
raise KnabenAPIError(f"Failed to connect to Knaben API: {e}") from e
def search(
self,
query: str,
limit: int = 10
) -> List[TorrentResult]:
def search(self, query: str, limit: int = 10) -> list[TorrentResult]:
"""
Search for torrents.
@@ -138,7 +134,7 @@ class KnabenClient:
# Parse results
results = []
torrents = data.get('hits', [])
torrents = data.get("hits", [])
if not torrents:
logger.info(f"No torrents found for '{query}'")
@@ -155,7 +151,7 @@ class KnabenClient:
logger.info(f"Found {len(results)} torrents for '{query}'")
return results
def _parse_torrent(self, torrent: Dict[str, Any]) -> TorrentResult:
def _parse_torrent(self, torrent: dict[str, Any]) -> TorrentResult:
"""
Parse a torrent result into a TorrentResult object.
@@ -166,17 +162,17 @@ class KnabenClient:
TorrentResult object
"""
# Extract required fields (API uses camelCase)
title = torrent.get('title', 'Unknown')
size = torrent.get('size', 'Unknown')
seeders = int(torrent.get('seeders', 0) or 0)
leechers = int(torrent.get('leechers', 0) or 0)
magnet = torrent.get('magnetUrl', '')
title = torrent.get("title", "Unknown")
size = torrent.get("size", "Unknown")
seeders = int(torrent.get("seeders", 0) or 0)
leechers = int(torrent.get("leechers", 0) or 0)
magnet = torrent.get("magnetUrl", "")
# Extract optional fields
info_hash = torrent.get('hash')
tracker = torrent.get('tracker')
upload_date = torrent.get('date')
category = torrent.get('category')
info_hash = torrent.get("hash")
tracker = torrent.get("tracker")
upload_date = torrent.get("date")
category = torrent.get("category")
return TorrentResult(
title=title,
@@ -187,5 +183,5 @@ class KnabenClient:
info_hash=info_hash,
tracker=tracker,
upload_date=upload_date,
category=category
category=category,
)
+6 -5
View File
@@ -1,17 +1,18 @@
"""Knaben Data Transfer Objects."""
from dataclasses import dataclass
from typing import Optional
@dataclass
class TorrentResult:
"""Represents a torrent search result from Knaben."""
title: str
size: str
seeders: int
leechers: int
magnet: str
info_hash: Optional[str] = None
tracker: Optional[str] = None
upload_date: Optional[str] = None
category: Optional[str] = None
info_hash: str | None = None
tracker: str | None = None
upload_date: str | None = None
category: str | None = None
+4
View File
@@ -3,19 +3,23 @@
class KnabenError(Exception):
"""Base exception for Knaben-related errors."""
pass
class KnabenConfigurationError(KnabenError):
"""Raised when Knaben API is not properly configured."""
pass
class KnabenAPIError(KnabenError):
"""Raised when Knaben API returns an error."""
pass
class KnabenNotFoundError(KnabenError):
"""Raised when no torrents are found."""
pass
+3 -2
View File
@@ -1,11 +1,12 @@
"""qBittorrent API client."""
from .client import QBittorrentClient
from .dto import TorrentInfo
from .exceptions import (
QBittorrentError,
QBittorrentConfigurationError,
QBittorrentAPIError,
QBittorrentAuthError,
QBittorrentConfigurationError,
QBittorrentError,
)
# Global qBittorrent client instance (singleton)
+35 -38
View File
@@ -1,12 +1,15 @@
"""qBittorrent Web API client."""
from typing import Dict, Any, Optional, List
import logging
from typing import Any
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from requests.exceptions import HTTPError, RequestException, Timeout
from agent.config import Settings, settings
from .dto import TorrentInfo
from .exceptions import QBittorrentError, QBittorrentAPIError, QBittorrentAuthError
from .exceptions import QBittorrentAPIError, QBittorrentAuthError
logger = logging.getLogger(__name__)
@@ -27,11 +30,11 @@ class QBittorrentClient:
def __init__(
self,
host: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
timeout: Optional[int] = None,
config: Optional[Settings] = None
host: str | None = None,
username: str | None = None,
password: str | None = None,
timeout: int | None = None,
config: Settings | None = None,
):
"""
Initialize qBittorrent client.
@@ -59,8 +62,8 @@ class QBittorrentClient:
self,
method: str,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
files: Optional[Dict[str, Any]] = None
data: dict[str, Any] | None = None,
files: dict[str, Any] | None = None,
) -> Any:
"""
Make a request to qBittorrent API.
@@ -85,7 +88,9 @@ class QBittorrentClient:
if method.upper() == "GET":
response = self.session.get(url, params=data, timeout=self.timeout)
elif method.upper() == "POST":
response = self.session.post(url, data=data, files=files, timeout=self.timeout)
response = self.session.post(
url, data=data, files=files, timeout=self.timeout
)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
@@ -99,14 +104,18 @@ class QBittorrentClient:
except Timeout as e:
logger.error(f"qBittorrent API timeout: {e}")
raise QBittorrentAPIError(f"Request timeout after {self.timeout} seconds") from e
raise QBittorrentAPIError(
f"Request timeout after {self.timeout} seconds"
) from e
except HTTPError as e:
logger.error(f"qBittorrent API HTTP error: {e}")
if e.response is not None:
status_code = e.response.status_code
if status_code == 403:
raise QBittorrentAuthError("Authentication required or forbidden") from e
raise QBittorrentAuthError(
"Authentication required or forbidden"
) from e
else:
raise QBittorrentAPIError(f"HTTP {status_code}: {e}") from e
raise QBittorrentAPIError(f"HTTP error: {e}") from e
@@ -126,10 +135,7 @@ class QBittorrentClient:
QBittorrentAuthError: If authentication fails
"""
try:
data = {
"username": self.username,
"password": self.password
}
data = {"username": self.username, "password": self.password}
response = self._make_request("POST", "/api/v2/auth/login", data=data)
@@ -161,10 +167,8 @@ class QBittorrentClient:
return False
def get_torrents(
self,
filter: Optional[str] = None,
category: Optional[str] = None
) -> List[TorrentInfo]:
self, filter: str | None = None, category: str | None = None
) -> list[TorrentInfo]:
"""
Get list of torrents.
@@ -212,9 +216,9 @@ class QBittorrentClient:
def add_torrent(
self,
magnet: str,
category: Optional[str] = None,
save_path: Optional[str] = None,
paused: bool = False
category: str | None = None,
save_path: str | None = None,
paused: bool = False,
) -> bool:
"""
Add a torrent via magnet link.
@@ -234,10 +238,7 @@ class QBittorrentClient:
if not self._authenticated:
self.login()
data = {
"urls": magnet,
"paused": "true" if paused else "false"
}
data = {"urls": magnet, "paused": "true" if paused else "false"}
if category:
data["category"] = category
@@ -248,7 +249,7 @@ class QBittorrentClient:
response = self._make_request("POST", "/api/v2/torrents/add", data=data)
if response == "Ok.":
logger.info(f"Successfully added torrent")
logger.info("Successfully added torrent")
return True
else:
logger.warning(f"Unexpected response: {response}")
@@ -258,11 +259,7 @@ class QBittorrentClient:
logger.error(f"Failed to add torrent: {e}")
raise
def delete_torrent(
self,
torrent_hash: str,
delete_files: bool = False
) -> bool:
def delete_torrent(self, torrent_hash: str, delete_files: bool = False) -> bool:
"""
Delete a torrent.
@@ -281,7 +278,7 @@ class QBittorrentClient:
data = {
"hashes": torrent_hash,
"deleteFiles": "true" if delete_files else "false"
"deleteFiles": "true" if delete_files else "false",
}
try:
@@ -339,7 +336,7 @@ class QBittorrentClient:
logger.error(f"Failed to resume torrent: {e}")
raise
def get_torrent_properties(self, torrent_hash: str) -> Dict[str, Any]:
def get_torrent_properties(self, torrent_hash: str) -> dict[str, Any]:
"""
Get detailed properties of a torrent.
@@ -361,7 +358,7 @@ class QBittorrentClient:
logger.error(f"Failed to get torrent properties: {e}")
raise
def _parse_torrent(self, torrent: Dict[str, Any]) -> TorrentInfo:
def _parse_torrent(self, torrent: dict[str, Any]) -> TorrentInfo:
"""
Parse a torrent dict into a TorrentInfo object.
@@ -384,5 +381,5 @@ class QBittorrentClient:
num_leechs=torrent.get("num_leechs", 0),
ratio=torrent.get("ratio", 0.0),
category=torrent.get("category"),
save_path=torrent.get("save_path")
save_path=torrent.get("save_path"),
)
+4 -3
View File
@@ -1,11 +1,12 @@
"""qBittorrent Data Transfer Objects."""
from dataclasses import dataclass
from typing import Optional
@dataclass
class TorrentInfo:
"""Represents a torrent in qBittorrent."""
hash: str
name: str
size: int
@@ -17,5 +18,5 @@ class TorrentInfo:
num_seeds: int
num_leechs: int
ratio: float
category: Optional[str] = None
save_path: Optional[str] = None
category: str | None = None
save_path: str | None = None
@@ -3,19 +3,23 @@
class QBittorrentError(Exception):
"""Base exception for qBittorrent-related errors."""
pass
class QBittorrentConfigurationError(QBittorrentError):
"""Raised when qBittorrent is not properly configured."""
pass
class QBittorrentAPIError(QBittorrentError):
"""Raised when qBittorrent API returns an error."""
pass
class QBittorrentAuthError(QBittorrentError):
"""Raised when authentication fails."""
pass
+4 -3
View File
@@ -1,10 +1,11 @@
"""TMDB API client."""
from .client import TMDBClient
from .dto import MediaResult, ExternalIds
from .dto import ExternalIds, MediaResult
from .exceptions import (
TMDBError,
TMDBConfigurationError,
TMDBAPIError,
TMDBConfigurationError,
TMDBError,
TMDBNotFoundError,
)
+104 -95
View File
@@ -1,12 +1,19 @@
"""TMDB (The Movie Database) API client."""
from typing import Dict, Any, Optional, List
import logging
from typing import Any
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from requests.exceptions import HTTPError, RequestException, Timeout
from agent.config import Settings, settings
from .dto import MediaResult
from .exceptions import TMDBError, TMDBConfigurationError, TMDBAPIError, TMDBNotFoundError
from .exceptions import (
TMDBAPIError,
TMDBConfigurationError,
TMDBNotFoundError,
)
logger = logging.getLogger(__name__)
@@ -14,88 +21,86 @@ logger = logging.getLogger(__name__)
class TMDBClient:
"""
Client for interacting with The Movie Database (TMDB) API.
This client provides methods to search for movies and TV shows,
retrieve their details, and get external IDs (like IMDb).
Example:
>>> client = TMDBClient()
>>> result = client.search_media("Inception")
>>> print(result.imdb_id)
'tt1375666'
"""
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: Optional[int] = None,
config: Optional[Settings] = None
api_key: str | None = None,
base_url: str | None = None,
timeout: int | None = None,
config: Settings | None = None,
):
"""
Initialize TMDB client.
Args:
api_key: TMDB API key (defaults to settings)
base_url: TMDB API base URL (defaults to settings)
timeout: Request timeout in seconds (defaults to settings)
config: Optional Settings instance (for testing)
Raises:
TMDBConfigurationError: If API key is missing
"""
cfg = config or settings
self.api_key = api_key or cfg.tmdb_api_key
self.base_url = base_url or cfg.tmdb_base_url
self.timeout = timeout or cfg.request_timeout
if not self.api_key:
raise TMDBConfigurationError(
"TMDB API key is required. Set TMDB_API_KEY environment variable."
)
if not self.base_url:
raise TMDBConfigurationError(
"TMDB base URL is required. Set TMDB_BASE_URL environment variable."
)
logger.info("TMDB client initialized")
def _make_request(
self,
endpoint: str,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
self, endpoint: str, params: dict[str, Any] | None = None
) -> dict[str, Any]:
"""
Make a request to TMDB API.
Args:
endpoint: API endpoint (e.g., '/search/multi')
params: Query parameters
Returns:
JSON response as dict
Raises:
TMDBAPIError: If request fails
"""
url = f"{self.base_url}{endpoint}"
# Add API key to params
request_params = params or {}
request_params['api_key'] = self.api_key
request_params["api_key"] = self.api_key
try:
logger.debug(f"TMDB request: {endpoint}")
response = requests.get(url, params=request_params, timeout=self.timeout)
response.raise_for_status()
return response.json()
except Timeout as e:
logger.error(f"TMDB API timeout: {e}")
raise TMDBAPIError(f"Request timeout after {self.timeout} seconds") from e
except HTTPError as e:
logger.error(f"TMDB API HTTP error: {e}")
if e.response is not None:
@@ -107,129 +112,133 @@ class TMDBClient:
else:
raise TMDBAPIError(f"HTTP {status_code}: {e}") from e
raise TMDBAPIError(f"HTTP error: {e}") from e
except RequestException as e:
logger.error(f"TMDB API request failed: {e}")
raise TMDBAPIError(f"Failed to connect to TMDB API: {e}") from e
def search_multi(self, query: str) -> List[Dict[str, Any]]:
def search_multi(self, query: str) -> list[dict[str, Any]]:
"""
Search for movies and TV shows.
Args:
query: Search query (movie or TV show title)
Returns:
List of search results
Raises:
TMDBAPIError: If request fails
TMDBNotFoundError: If no results found
"""
if not query or not isinstance(query, str):
raise ValueError("Query must be a non-empty string")
if len(query) > 500:
raise ValueError("Query is too long (max 500 characters)")
data = self._make_request('/search/multi', {'query': query})
results = data.get('results', [])
data = self._make_request("/search/multi", {"query": query})
results = data.get("results", [])
if not results:
raise TMDBNotFoundError(f"No results found for '{query}'")
logger.info(f"Found {len(results)} results for '{query}'")
return results
def get_external_ids(self, media_type: str, tmdb_id: int) -> Dict[str, Any]:
def get_external_ids(self, media_type: str, tmdb_id: int) -> dict[str, Any]:
"""
Get external IDs (IMDb, TVDB, etc.) for a media item.
Args:
media_type: Type of media ('movie' or 'tv')
tmdb_id: TMDB ID of the media
Returns:
Dict with external IDs
Raises:
TMDBAPIError: If request fails
"""
if media_type not in ('movie', 'tv'):
raise ValueError(f"Invalid media_type: {media_type}. Must be 'movie' or 'tv'")
if media_type not in ("movie", "tv"):
raise ValueError(
f"Invalid media_type: {media_type}. Must be 'movie' or 'tv'"
)
endpoint = f"/{media_type}/{tmdb_id}/external_ids"
return self._make_request(endpoint)
def search_media(self, title: str) -> MediaResult:
"""
Search for a media item and return detailed information including IMDb ID.
This is a convenience method that combines search and external ID lookup.
Args:
title: Title of the movie or TV show
Returns:
MediaResult with all available information
Raises:
TMDBAPIError: If request fails
TMDBNotFoundError: If media not found
"""
# Search for media
results = self.search_multi(title)
# Get the first (most relevant) result
top_result = results[0]
# Validate result structure
if 'id' not in top_result or 'media_type' not in top_result:
if "id" not in top_result or "media_type" not in top_result:
raise TMDBAPIError("Invalid TMDB response structure")
tmdb_id = top_result['id']
media_type = top_result['media_type']
tmdb_id = top_result["id"]
media_type = top_result["media_type"]
# Skip if not movie or TV show
if media_type not in ('movie', 'tv'):
if media_type not in ("movie", "tv"):
logger.warning(f"Skipping result of type: {media_type}")
if len(results) > 1:
# Try next result
return self._parse_result(results[1])
raise TMDBNotFoundError(f"No movie or TV show found for '{title}'")
return self._parse_result(top_result)
def _parse_result(self, result: Dict[str, Any]) -> MediaResult:
def _parse_result(self, result: dict[str, Any]) -> MediaResult:
"""
Parse a TMDB result into a MediaResult object.
Args:
result: Raw TMDB result dict
Returns:
MediaResult object
"""
tmdb_id = result['id']
media_type = result['media_type']
title = result.get('title') or result.get('name', 'Unknown')
tmdb_id = result["id"]
media_type = result["media_type"]
title = result.get("title") or result.get("name", "Unknown")
# Get external IDs (including IMDb)
try:
external_ids = self.get_external_ids(media_type, tmdb_id)
imdb_id = external_ids.get('imdb_id')
imdb_id = external_ids.get("imdb_id")
except TMDBAPIError as e:
logger.warning(f"Failed to get external IDs: {e}")
imdb_id = None
# Extract other useful information
overview = result.get('overview')
release_date = result.get('release_date') or result.get('first_air_date')
poster_path = result.get('poster_path')
vote_average = result.get('vote_average')
logger.info(f"Found: {title} (Type: {media_type}, TMDB ID: {tmdb_id}, IMDb: {imdb_id})")
overview = result.get("overview")
release_date = result.get("release_date") or result.get("first_air_date")
poster_path = result.get("poster_path")
vote_average = result.get("vote_average")
logger.info(
f"Found: {title} (Type: {media_type}, TMDB ID: {tmdb_id}, IMDb: {imdb_id})"
)
return MediaResult(
tmdb_id=tmdb_id,
title=title,
@@ -238,43 +247,43 @@ class TMDBClient:
overview=overview,
release_date=release_date,
poster_path=poster_path,
vote_average=vote_average
vote_average=vote_average,
)
def get_movie_details(self, movie_id: int) -> Dict[str, Any]:
def get_movie_details(self, movie_id: int) -> dict[str, Any]:
"""
Get detailed information about a movie.
Args:
movie_id: TMDB movie ID
Returns:
Dict with movie details
Raises:
TMDBAPIError: If request fails
"""
return self._make_request(f'/movie/{movie_id}')
def get_tv_details(self, tv_id: int) -> Dict[str, Any]:
return self._make_request(f"/movie/{movie_id}")
def get_tv_details(self, tv_id: int) -> dict[str, Any]:
"""
Get detailed information about a TV show.
Args:
tv_id: TMDB TV show ID
Returns:
Dict with TV show details
Raises:
TMDBAPIError: If request fails
"""
return self._make_request(f'/tv/{tv_id}')
return self._make_request(f"/tv/{tv_id}")
def is_configured(self) -> bool:
"""
Check if TMDB client is properly configured.
Returns:
True if configured, False otherwise
"""
+13 -11
View File
@@ -1,26 +1,28 @@
"""TMDB Data Transfer Objects."""
from dataclasses import dataclass
from typing import Optional
@dataclass
class MediaResult:
"""Represents a media search result from TMDB."""
tmdb_id: int
title: str
media_type: str # 'movie' or 'tv'
imdb_id: Optional[str] = None
overview: Optional[str] = None
release_date: Optional[str] = None
poster_path: Optional[str] = None
vote_average: Optional[float] = None
imdb_id: str | None = None
overview: str | None = None
release_date: str | None = None
poster_path: str | None = None
vote_average: float | None = None
@dataclass
class ExternalIds:
"""External IDs for a media item."""
imdb_id: Optional[str] = None
tvdb_id: Optional[int] = None
facebook_id: Optional[str] = None
instagram_id: Optional[str] = None
twitter_id: Optional[str] = None
imdb_id: str | None = None
tvdb_id: int | None = None
facebook_id: str | None = None
instagram_id: str | None = None
twitter_id: str | None = None
+4
View File
@@ -3,19 +3,23 @@
class TMDBError(Exception):
"""Base exception for TMDB-related errors."""
pass
class TMDBConfigurationError(TMDBError):
"""Raised when TMDB API is not properly configured."""
pass
class TMDBAPIError(TMDBError):
"""Raised when TMDB API returns an error."""
pass
class TMDBNotFoundError(TMDBError):
"""Raised when media is not found."""
pass