"""Typed client for the official Agents Course GAIA API contract.""" from __future__ import annotations import hashlib import json from pathlib import Path from typing import Any import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from config import Settings class GaiaClientError(RuntimeError): pass class GaiaNetworkError(GaiaClientError): pass class GaiaHTTPError(GaiaClientError): pass class GaiaPayloadError(GaiaClientError): pass class GaiaClient: def __init__(self, settings: Settings, session: requests.Session | None = None): self.settings = settings self.session = session or requests.Session() if hasattr(self.session, "headers"): self.session.headers.setdefault("User-Agent", settings.user_agent) retry = Retry( total=settings.retries, connect=settings.retries, read=settings.retries, status=settings.retries, backoff_factor=settings.backoff_seconds, status_forcelist=(429, 500, 502, 503, 504), allowed_methods=frozenset({"GET"}), raise_on_status=False, ) adapter = HTTPAdapter(max_retries=retry) self.session.mount("https://", adapter) self.session.mount("http://", adapter) def _get_json(self, path: str) -> Any: try: response = self.session.get( f"{self.settings.api_url}{path}", timeout=self.settings.request_timeout ) response.raise_for_status() return response.json() except requests.HTTPError as exc: raise GaiaHTTPError( f"GET {path} returned HTTP {exc.response.status_code}" ) from exc except (requests.ConnectionError, requests.Timeout) as exc: raise GaiaNetworkError(f"GET {path} failed: {exc}") from exc except requests.JSONDecodeError as exc: raise GaiaPayloadError(f"GET {path} returned invalid JSON") from exc except requests.RequestException as exc: raise GaiaNetworkError(f"GET {path} failed: {exc}") from exc def get_questions(self) -> list[dict[str, Any]]: data = self._get_json("/questions") if not isinstance(data, list) or not data: raise GaiaPayloadError( "Questions endpoint returned an empty or invalid payload" ) for item in data: if ( not isinstance(item, dict) or not item.get("task_id") or item.get("question") is None ): raise GaiaPayloadError("Questions endpoint returned a malformed task") return data def fetch_questions(self) -> list[dict[str, Any]]: """Backward-compatible alias.""" return self.get_questions() def get_random_question(self) -> dict[str, Any]: data = self._get_json("/random-question") if ( not isinstance(data, dict) or not data.get("task_id") or data.get("question") is None ): raise GaiaPayloadError("Random-question endpoint returned a malformed task") return data def download_task_file(self, task_id: str, filename: str) -> Path: safe_name = Path(filename).name if not safe_name: raise ValueError("filename is required") directory = self.settings.cache_dir / "attachments" / str(task_id) destination = directory / safe_name metadata_path = directory / "metadata.json" directory.mkdir(parents=True, exist_ok=True) def record_failure(error: str, content_type: str = "") -> None: metadata_path.write_text( json.dumps( { "task_id": str(task_id), "filename": safe_name, "content_type": content_type, "status": "failed", "error": error, "source_url": f"{self.settings.api_url}/files/{task_id}", }, indent=2, ), encoding="utf-8", ) if destination.is_file() and metadata_path.is_file(): metadata = json.loads(metadata_path.read_text(encoding="utf-8")) checksum = hashlib.sha256(destination.read_bytes()).hexdigest() if ( metadata.get("status") == "complete" and metadata.get("sha256") == checksum ): return destination source_url = f"{self.settings.api_url}/files/{task_id}" try: response = self.session.get( source_url, timeout=max(self.settings.request_timeout, 120), ) response.raise_for_status() except requests.HTTPError as exc: if not self.settings.hf_token: record_failure( f"HTTP {exc.response.status_code}; no HF_TOKEN for dataset fallback" ) raise GaiaHTTPError( f"Attachment {task_id} returned HTTP {exc.response.status_code}; " "HF_TOKEN is required for the official GAIA dataset fallback" ) from exc source_url = ( "https://huggingface.co/datasets/gaia-benchmark/GAIA/resolve/main/" f"2023/validation/{safe_name}" ) try: response = self.session.get( source_url, timeout=max(self.settings.request_timeout, 120), headers={"Authorization": f"Bearer {self.settings.hf_token}"}, ) response.raise_for_status() except requests.RequestException as fallback_exc: status = getattr( getattr(fallback_exc, "response", None), "status_code", "network error", ) record_failure( f"Course endpoint HTTP {exc.response.status_code}; dataset fallback {status}" ) access_hint = ( " Accept the GAIA dataset access conditions at " "https://huggingface.co/datasets/gaia-benchmark/GAIA and use " "a read-enabled HF_TOKEN." if status in (401, 403) else "" ) raise GaiaHTTPError( f"Attachment {task_id} unavailable from course endpoint and " f"GAIA dataset fallback.{access_hint}" ) from fallback_exc except ( requests.ConnectionError, requests.Timeout, requests.RequestException, ) as exc: if not self.settings.hf_token: record_failure( f"{type(exc).__name__}: {exc}; no HF_TOKEN for dataset fallback" ) raise GaiaNetworkError(f"Attachment {task_id} failed: {exc}") from exc source_url = ( "https://huggingface.co/datasets/gaia-benchmark/GAIA/resolve/main/" f"2023/validation/{safe_name}" ) try: response = self.session.get( source_url, timeout=max(self.settings.request_timeout, 120), headers={"Authorization": f"Bearer {self.settings.hf_token}"}, ) response.raise_for_status() except requests.RequestException as fallback_exc: record_failure( f"Course endpoint {type(exc).__name__}; dataset fallback " f"{type(fallback_exc).__name__}" ) raise GaiaNetworkError( f"Attachment {task_id} failed from course endpoint and GAIA dataset fallback" ) from fallback_exc content_type = response.headers.get("content-type", "application/octet-stream") if "application/json" in content_type.lower(): record_failure("Attachment endpoint returned JSON", content_type) raise GaiaPayloadError( f"Attachment endpoint returned JSON: {response.text[:500]}" ) if not response.content: record_failure("Attachment was empty", content_type) raise GaiaPayloadError(f"Attachment for {task_id} was empty") temporary = destination.with_suffix(destination.suffix + ".tmp") temporary.write_bytes(response.content) temporary.replace(destination) metadata = { "task_id": str(task_id), "filename": safe_name, "content_type": content_type, "size": len(response.content), "sha256": hashlib.sha256(response.content).hexdigest(), "status": "complete", "source_url": source_url, } metadata_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8") return destination def download_attachment(self, task: dict[str, Any]) -> Path | None: filename = str(task.get("file_name") or "").strip() return ( self.download_task_file(str(task["task_id"]), filename) if filename else None ) def submit_answers( self, username: str, agent_code: str, answers: list[dict[str, str]] ) -> dict[str, Any]: payload = { "username": username.strip(), "agent_code": agent_code, "answers": answers, } try: response = self.session.post( f"{self.settings.api_url}/submit", json=payload, timeout=max(self.settings.request_timeout, 60), ) response.raise_for_status() data = response.json() except requests.HTTPError as exc: raise GaiaHTTPError( f"Submission returned HTTP {exc.response.status_code}" ) from exc except requests.JSONDecodeError as exc: raise GaiaPayloadError("Submission returned invalid JSON") from exc except requests.RequestException as exc: raise GaiaNetworkError(f"Submission network failure: {exc}") from exc if not isinstance(data, dict): raise GaiaPayloadError("Submission endpoint returned an invalid payload") return data def submit( self, username: str, agent_code: str, answers: list[dict[str, str]] ) -> dict[str, Any]: """Backward-compatible alias for the explicit submission action.""" return self.submit_answers(username, agent_code, answers)